Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
52 changes: 52 additions & 0 deletions .sync/log/64b9f7dd93863370ada09dc3be31bb05a8a5a4ce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Port: feat(ContentToc): scroll list independently and center active link (#6697)

**Upstream:** `64b9f7dd93863370ada09dc3be31bb05a8a5a4ce` (nuxt/ui)
**Decision:** port (adapted to b24ui's TOC layout)

## Upstream change
On desktop the ContentToc list becomes independently scrollable (the outer
sticky wrapper stops scrolling — `lg:overflow-hidden` — and the inner content
list scrolls with `lg:overflow-y-auto`), and the active link is kept centered:
an `activeIndex` computed is extracted from `indicatorStyle`, and a
`watch(activeIndex)` scrolls the content container (not the page) to center the
active link on change. A scroll shadow (`useScrollShadow`) is applied to the
content. Theme adds `flex flex-col` + `overflow` handling on root, `shrink-0`
on the fixed rows, `min-h-0` on the flexing ones, and `overflow-y-auto` on the
content. `[...slug].vue` additionally enables `highlight`/`circuit`.

## b24ui port — adapted
b24ui's ContentToc already diverges (no indicator/highlight machinery — those
are `@memo not supported`), and its sticky/`max-h`/`overflow-y-auto` wrapper
styling lives on the docs `class` prop, not the theme `root` (which was empty).
The independent-scroll + centering feature is orthogonal to the indicator, so it
ports cleanly:

- `src/runtime/components/content/ContentToc.vue` — added `useScrollShadow`
import, `contentRef` + `scrollShadowStyle`, the `activeIndex` computed, and the
centering `watch` (verbatim; b24ui links already carry `data-slot="link"`).
The desktop content `<div>` gets `ref="contentRef"` + `:style="scrollShadowStyle"`.
- `src/theme/content/content-toc.ts` — `root` → `flex flex-col lg:overflow-hidden`
(b24ui's docs `class` keeps the sticky/`max-h`/mobile `overflow-y-auto`);
`container` +`lg:min-h-0`; `top`/`bottom`/`trigger` +`lg:shrink-0`; `content`
+`lg:min-h-0 lg:overflow-y-auto`.

### Deviation — scrollbar utility
Upstream hides the list scrollbar with `lg:scrollbar-none`. b24ui has no
`scrollbar-none` utility — its scrollbar helpers (`air-design-tokens/components/
scrollbar.css`) are `scrollbar-thin` / `scrollbar-transparent`. Used
`lg:scrollbar-thin lg:scrollbar-transparent` (b24ui's idiom, matching the sidebar
and the docs ContentToc wrapper) instead.

### Not ported
`docs/app/pages/docs/[...slug].vue` — the only hunk adds `highlight`
`highlight-variant="circuit"`, which b24ui explicitly does not support. No change.

## Tests
No component spec changes upstream. 16 ContentToc snapshots updated — class-only
(root gains `flex flex-col lg:overflow-hidden`; content gains the `lg:` overflow/
scrollbar classes; rows gain `lg:shrink-0`/`lg:min-h-0`). `scrollShadowStyle` is
`undefined` when not overflowing, so no inline style appears in snapshots. Suite
5309 passed / 6 skipped.

## Verify (CI=true)
`dev:prepare` · `lint` · `typecheck` · `test` · `build` — all green.
10 changes: 8 additions & 2 deletions .sync/nuxt-ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"upstream": "nuxt/ui",
"branch": "v4",
"sync_enabled": false,
"cursor": "1a3a9dc35b6a4e7a3aea57d93532b155de51c0ec",
"cursor": "64b9f7dd93863370ada09dc3be31bb05a8a5a4ce",
"_cursor_note": "cursor = last upstream commit ported into b24ui (oldest-first, manual cadence). sync_enabled stays false until Phase 2 (porter workflow #67 + CLAUDE_CODE_OAUTH_TOKEN) is wired and trusted. `processed` is maintained per port from now on (backfilled #68-#72 on 2026-06-09).",
"stats": {
"queue_depth": 0,
Expand Down Expand Up @@ -947,10 +947,16 @@
"summary": "fix(inertia): make useRoute().fullPath reactive across navigations (#6696) — inertia useRoute stub returned fullPath: page.url (captured once, stale across navigations). Change to getter get fullPath() { return page.url }. b24ui: src/runtime/vue/stubs/inertia.ts matched, applied verbatim. Direct 1:1. Runtime stub, no snapshot churn. Tests 5289"
},
"1a3a9dc35b6a4e7a3aea57d93532b155de51c0ec": {
"pr": 257,
"b24ui_sha": "5f789029ecb613c900b1b33bc6dcac332eab8b71",
"decision": "port",
"summary": "feat(ChatTool): add actions prop for tool approval (#6699) — ChatTool gains actions?: ButtonProps[] rendering a button row below the trigger + actions slot, auto-opens once when actions appear (uncontrolled, respects defaultOpen). Theme: actions slot (inline/card), chevron.leading->'' with group-hover:opacity-0 moved into alone.false.leadingIcon. ai.ts: isToolApprovalPending() + isToolStreaming treats approval-requested as paused (pure string helpers, harmless on v6). Ported component/theme/util/docs/playgrounds/tests. DEFERRED the v7 tool-approval flow (chat.md, chat.vue, chat.post.ts, ChatToolApprovalExample.vue — toolApproval/addToolApprovalResponse). Skipped chat-message.ts body:w-full (b24ui has no naked variant). Tests 5309."
},
"64b9f7dd93863370ada09dc3be31bb05a8a5a4ce": {
"pr": null,
"b24ui_sha": "pending-merge",
"decision": "port",
"summary": "feat(ChatTool): add actions prop for tool approval (#6699) — ChatTool gains actions?: ButtonProps[] rendering a button row below the trigger + actions slot, auto-opens once when actions appear (uncontrolled, respects defaultOpen). Theme: actions slot (inline/card), chevron.leading->'' with group-hover:opacity-0 moved into alone.false.leadingIcon. ai.ts: isToolApprovalPending() + isToolStreaming treats approval-requested as paused (pure string helpers, harmless on v6). Ported component/theme/util/docs/playgrounds/tests. DEFERRED the v7 tool-approval flow (chat.md, chat.vue, chat.post.ts, ChatToolApprovalExample.vue — toolApproval/addToolApprovalResponse). Skipped chat-message.ts body:w-full (b24ui has no naked variant). Tests 5309."
"summary": "feat(ContentToc): scroll list independently and center active link (#6697) — desktop TOC list scrolls independently (root lg:overflow-hidden + content lg:overflow-y-auto) and active link is centered via activeIndex computed + watch scrolling contentRef; useScrollShadow on content. b24ui adapted: sticky/max-h stays on docs class prop; theme root flex flex-col lg:overflow-hidden, container/top/bottom/trigger get min-h-0/shrink-0, content lg:overflow-y-auto. Deviation: no scrollbar-none in b24ui -> lg:scrollbar-thin lg:scrollbar-transparent. Not ported: [...slug].vue highlight/circuit (b24ui unsupported). 16 snapshots class-only. Tests 5309."
}
}
}
42 changes: 40 additions & 2 deletions src/runtime/components/content/ContentToc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,14 @@ export interface ContentTocSlots<T extends ContentTocLink = ContentTocLink> {
</script>

<script setup lang="ts" generic="T extends ContentTocLink">
import { computed, onUnmounted } from 'vue'
import { computed, onUnmounted, useTemplateRef, watch, nextTick } from 'vue'
import { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent } from 'reka-ui'
import { reactivePick, createReusableTemplate } from '@vueuse/core'
import { useRouter, useAppConfig, useNuxtApp } from '#imports'
import { useComponentProps } from '../../composables/useComponentProps'
import { useForwardProps } from '../../composables/useForwardProps'
import { useScrollspy } from '../../composables/useScrollspy'
import { useScrollShadow } from '../../composables/useScrollShadow'
import { useLocale } from '../../composables/useLocale'
import { usePrefix } from '../../composables/usePrefix'
import { tv } from '../../utils/tv'
Expand All @@ -88,6 +89,9 @@ const appConfig = useAppConfig() as ContentToc['AppConfig']
const prefix = usePrefix()
const { activeHeadings, updateHeadings } = useScrollspy()

const contentRef = useTemplateRef<HTMLElement>('contentRef')
const { style: scrollShadowStyle } = useScrollShadow(contentRef)

const [DefineListTemplate, ReuseListTemplate] = createReusableTemplate<{ links: T[], level: number }>({
props: {
links: Array,
Expand Down Expand Up @@ -119,6 +123,40 @@ function flattenLinks(links: T[]): T[] {

// const linkHeight = 1.75 // rem — text-sm line-height (1.25rem) + py-1 (0.5rem)

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

return flattenLinks(props.links || []).findIndex(link => activeHeadings.value.includes(link.id))
})

// Keep the active link centered within the (desktop) list when it changes.
// Scroll the container directly rather than `scrollIntoView` so only the list
// moves, never the page.
watch(activeIndex, (index) => {
const container = contentRef.value
if (index < 0 || !container) {
return
}

nextTick(() => {
const link = container.querySelectorAll<HTMLElement>('a[data-slot="link"]')[index]
if (!link) {
return
}

const containerRect = container.getBoundingClientRect()
const linkRect = link.getBoundingClientRect()
const linkOffset = (linkRect.top - containerRect.top) + container.scrollTop

container.scrollTo({
top: linkOffset - container.clientHeight / 2 + linkRect.height / 2,
behavior: 'smooth'
})
})
})

const nuxtApp = useNuxtApp()

function refreshHeadings() {
Expand Down Expand Up @@ -209,7 +247,7 @@ onUnmounted(() => {
<ReuseTriggerTemplate :open="open" />
</p>

<div data-slot="content" :class="b24ui.content({ class: [props.b24ui?.content, prefix('hidden lg:flex')] })">
<div ref="contentRef" data-slot="content" :class="b24ui.content({ class: [props.b24ui?.content, prefix('hidden lg:flex')] })" :style="scrollShadowStyle">
<slot name="content" :links="props.links">
<ReuseListTemplate :links="props.links" :level="0" />
</slot>
Expand Down
14 changes: 8 additions & 6 deletions src/theme/content/content-toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
*/
export default {
slots: {
root: '',
root: 'flex flex-col lg:overflow-hidden',
container: [
'border-dashed border-b-(length:--ui-border-width-thick) border-(--ui-color-divider-vibrant-default)',
'lg:border-0',
'flex flex-col'
'flex flex-col lg:min-h-0'
].join(' '),
top: '',
bottom: 'hidden lg:flex lg:flex-col gap-[24px]',
top: 'lg:shrink-0',
bottom: 'hidden lg:flex lg:flex-col lg:shrink-0 gap-[24px]',
trigger: [
'group',
'pb-[12px]',
Expand All @@ -25,7 +25,8 @@ export default {
'text-(length:--ui-font-size-lg)/(--ui-font-line-height-3xs)',
'font-(--ui-font-weight-semi-bold)',
'flex-1',
'flex items-center gap-[6px]'
'flex items-center gap-[6px]',
'lg:shrink-0'
].join(' '),
title: 'line-clamp-1 lg:line-clamp-2', // truncate
trailing: 'ms-auto inline-flex gap-[6px] items-center',
Expand All @@ -41,7 +42,8 @@ export default {
'motion-safe:data-[state=open]:animate-[collapsible-down_200ms_ease-out]',
'motion-safe:data-[state=closed]:animate-[collapsible-up_200ms_ease-out]',
'overflow-hidden',
'focus:outline-none'
'focus:outline-none',
'lg:min-h-0 lg:overflow-y-auto lg:scrollbar-thin lg:scrollbar-transparent'
].join(' '),
list: 'min-w-0',
listWithChildren: 'ms-[12px]',
Expand Down
Loading