Skip to content
Merged

Dev #32

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
6 changes: 6 additions & 0 deletions .changes/5e75d566.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: fixed
scope: Anchor
---

Keep the entire highlighted directory range visible when it fits, including changes to its last entry. Fall back to the first entry for oversized ranges without repeated recentering.
6 changes: 6 additions & 0 deletions .changes/65193d30.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: fixed
scope: PrevNext
---

Constrain the narrow-screen grid and title wrapper so long truncated titles stay inside navigation cards.
6 changes: 6 additions & 0 deletions .changes/bf988426.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: fixed
scope: lightbox
---

Prevent SSR memory leaks in Lightbox, Image preview and ImageGroup by keeping animation subscriptions out of server rendering.
6 changes: 6 additions & 0 deletions .changes/e6eba6e9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: fixed
scope: Anchor
---

Enable automatic directory scrolling by default when autoScroll is omitted, while preserving explicit opt-out.
2 changes: 1 addition & 1 deletion docs/app/components/docs/Banner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
const localePath = useLocalePath()
const key = 'hn-docs-banner:preview'
const { t } = useI18n()
const open = ref(true)
const open = useState('docs-banner-open', () => true)

onPrehydrate(() => {
document.querySelectorAll('[data-docs-banner]').forEach(el => {
Expand Down
6 changes: 3 additions & 3 deletions docs/content/en/components/anchor.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ Trailing content aligns to the end of the row without shrinking, while long labe

### Following a long directory {#scroll}

Place a long directory inside a height-constrained `ScrollArea`. When the current entry leaves the directory viewport, `Anchor` scrolls it fully into view with a small margin. It detects the nearest vertical scroll container, including native containers or a scrollable root element, without requiring a viewport ref.
Place a long directory inside a height-constrained `ScrollArea`. When the highlighted range extends beyond the directory viewport, `Anchor` scrolls the entire range into view with a small margin. If the range is taller than the viewport, it follows the first current entry instead of alternating between the two ends. It detects the nearest vertical scroll container, including native containers or a scrollable root element, without requiring a viewport ref.

Visible entries are not repeatedly centered, and browsing the directory manually does not continually pull it back. Initial positioning and size adjustments are instant; subsequent current-entry changes scroll smoothly unless reduced motion is enabled. Following never moves focus or scrolls an outer container shared with the article.
Fully visible ranges are not repeatedly centered, and browsing the directory manually does not continually pull it back. Initial positioning and size adjustments are instant; subsequent changes to either end of the highlighted range scroll smoothly unless reduced motion is enabled. Following never moves focus or scrolls an outer container shared with the article.

Set `:auto-scroll="false"` to disable following while keeping scroll-spy and highlighting. `@change` receives the current entry's `id`, and a component ref exposes the read-only `current` value, so observing `aria-current` is unnecessary. This example contains 51 entries; scroll the article and directory independently or disable following to compare.

Expand Down Expand Up @@ -79,7 +79,7 @@ Set `:auto-scroll="false"` to disable following while keeping scroll-spy and hig
| ------------ | --------- | ------------------ | -------------------------------------------------------------- |
| `items` | `T[]` | Required | Entries of the contents list |
| `label` | `string` | Interface language | Accessible name of the landmark |
| `autoScroll` | `boolean` | `true` | Keep the current entry visible in the directory's own viewport |
| `autoScroll` | `boolean` | `true` | Follow the highlighted range, or its first entry when too tall |
| `class` | `string` | — | Classes appended to the root |

### AnchorItem {#item}
Expand Down
6 changes: 3 additions & 3 deletions docs/content/zh-CN/components/anchor.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ import { Anchor } from '@hina-ui/vue'

### 长目录跟随 {#scroll}

把长目录放入限定高度的 `ScrollArea`,当前项超出目录可视区域时,`Anchor` 会自动滚动目录使其完整可见,并保留少量边距。它会识别最近的纵向滚动容器,也支持原生滚动容器或根元素自身滚动,无需传入 viewport。
把长目录放入限定高度的 `ScrollArea`,当前高亮段超出目录可视区域时,`Anchor` 会自动滚动目录使整段完整可见,并保留少量边距。高亮段比可视区域更高时,退回跟随段首当前项,不在首尾之间来回滚动。它会识别最近的纵向滚动容器,也支持原生滚动容器或根元素自身滚动,无需传入 viewport。

已经可见的条目不会反复居中,手动翻看目录也不会被持续拉回。首次定位和尺寸调整直接对齐,后续当前项变化时平滑跟随;减弱动态效果下始终瞬时定位。自动跟随不会移动焦点,也不会滚动同时包含正文的外层容器。
已经完整可见的高亮段不会反复居中,手动翻看目录也不会被持续拉回。首次定位和尺寸调整直接对齐,后续高亮段首尾变化时平滑跟随;减弱动态效果下始终瞬时定位。自动跟随不会移动焦点,也不会滚动同时包含正文的外层容器。

通过 `:auto-scroll="false"` 关闭自动跟随,scroll-spy 和高亮仍然工作。`@change` 接收当前条目的 `id`,组件 ref 上也可读取只读的 `current`,不需要观察 `aria-current`。下面的目录包含 51 项,可以独立滚动正文和目录,或关闭跟随作比较。

Expand Down Expand Up @@ -79,7 +79,7 @@ import { Anchor } from '@hina-ui/vue'
| ------------ | --------- | ------------ | ---------------------------------- |
| `items` | `T[]` | 必填 | 目录条目 |
| `label` | `string` | 取自界面语言 | 导航地标的无障碍名 |
| `autoScroll` | `boolean` | `true` | 自动将当前项滚入目录自身的可视区域 |
| `autoScroll` | `boolean` | `true` | 跟随当前高亮段;整段过高时跟随段首 |
| `class` | `string` | — | 追加到根元素的类 |

### AnchorItem {#item}
Expand Down
15 changes: 15 additions & 0 deletions docs/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { test } from 'node:test'

const locale = JSON.parse(
readFileSync(fileURLToPath(new URL('./i18n/locales/zh-CN.json', import.meta.url)), 'utf8'),
)

test('Chinese documentation sidebar gives every component a short name', () => {
assert.deepEqual(
Object.keys(locale.components).filter(key => !locale.names[key]),
[],
)
})
5 changes: 5 additions & 0 deletions docs/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@
},
"names": {
"accordion": "折叠列表",
"affix": "吸附",
"alert": "消息条",
"alert-dialog": "确认对话框",
"anchor": "页内目录",
Expand All @@ -400,6 +401,7 @@
"card": "卡片",
"calendar": "日历",
"callout": "标注",
"carousel": "轮播",
"center": "居中",
"checkbox": "复选框",
"checkbox-group": "复选框组",
Expand Down Expand Up @@ -450,8 +452,10 @@
"listbox": "列表框",
"loading-overlay": "加载遮罩",
"mark": "标记",
"masonry": "瀑布流",
"menubar": "菜单栏",
"meter-group": "占比条",
"month-grid": "月份日历",
"multi-select": "多选选择器",
"multi-combobox": "多选组合框",
"nav-link": "导航项",
Expand All @@ -467,6 +471,7 @@
"prev-next": "上下页",
"progress": "进度条",
"prose": "富文本",
"qr-code": "二维码",
"radio-group": "单选框组",
"range-calendar": "范围日历",
"range-slider": "范围滑块",
Expand Down
113 changes: 102 additions & 11 deletions packages/vue/src/components/anchor/Anchor.follow.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ async function settle() {
for (let i = 0; i < 4; i++) await new Promise(requestAnimationFrame)
}

function setup(kind: 'native' | 'scroll-area' | 'root' = 'scroll-area', follow = true) {
function setup(
kind: 'native' | 'scroll-area' | 'root' = 'scroll-area',
follow?: boolean,
sectionHeight = 240,
) {
const items = ref(
Array.from({ length: 51 }, (_, index) => ({
id: `follow-${index}`,
Expand All @@ -39,7 +43,7 @@ function setup(kind: 'native' | 'scroll-area' | 'root' = 'scroll-area', follow =
h(Anchor, {
ref: anchor,
items: items.value,
autoScroll: autoScroll.value,
...(autoScroll.value === undefined ? {} : { autoScroll: autoScroll.value }),
onChange: change,
class: '[&_a]:min-h-[30px]',
})
Expand All @@ -51,14 +55,14 @@ function setup(kind: 'native' | 'scroll-area' | 'root' = 'scroll-area', follow =
'div',
{ ref: article, style: 'height:320px;width:320px;overflow:auto' },
items.value.map(item =>
h('section', { id: item.id, style: 'height:240px' }, item.label),
h('section', { id: item.id, style: `height:${sectionHeight}px` }, item.label),
),
),
kind === 'root'
? h(Anchor, {
ref: anchor,
items: items.value,
autoScroll: autoScroll.value,
...(autoScroll.value === undefined ? {} : { autoScroll: autoScroll.value }),
onChange: change,
class: 'w-52 [&_a]:min-h-[30px]',
style: 'height:320px;overflow:auto',
Expand Down Expand Up @@ -89,22 +93,37 @@ function setup(kind: 'native' | 'scroll-area' | 'root' = 'scroll-area', follow =
const port = () =>
kind === 'root' ? nav() : kind === 'native' ? toc.value! : area.value!.viewport!
const active = () => nav().querySelector<HTMLElement>('a[aria-current]')!
const expectVisible = () => {
const covered = () => Array.from(nav().querySelectorAll<HTMLElement>('a.font-medium'))
const expectBoundsVisible = (first: HTMLElement, last = first) => {
const viewport = port()
const top = viewport.getBoundingClientRect().top + viewport.clientTop
const row = active().getBoundingClientRect()
expect(row.top).toBeGreaterThanOrEqual(top - 1)
expect(row.bottom).toBeLessThanOrEqual(top + viewport.clientHeight + 1)
expect(first.getBoundingClientRect().top).toBeGreaterThanOrEqual(top - 1)
expect(last.getBoundingClientRect().bottom).toBeLessThanOrEqual(top + viewport.clientHeight + 1)
}
const expectVisible = () => expectBoundsVisible(active())
const expectRangeVisible = () => expectBoundsVisible(covered()[0]!, covered().at(-1)!)
const go = async (index: number) => {
article.value!.scrollTop = 240 * index
article.value!.scrollTop = sectionHeight * index
await vi.waitFor(() => expect(anchor.value?.current).toBe(`follow-${index}`))
}
return { wrapper, items, autoScroll, article, anchor, change, port, active, expectVisible, go }
return {
wrapper,
items,
autoScroll,
article,
anchor,
change,
port,
active,
covered,
expectVisible,
expectRangeVisible,
go,
}
}

it.each(['native', 'scroll-area', 'root'] as const)(
'keeps the current entry visible in a long %s directory without moving the article or focus',
'keeps the current entry visible by default in a long %s directory without moving the article or focus',
async kind => {
const { go, port, expectVisible, article, change, anchor } = setup(kind)
await vi.waitFor(() => expect(anchor.value?.current).toBe('follow-0'))
Expand All @@ -125,6 +144,78 @@ it.each(['native', 'scroll-area', 'root'] as const)(
},
)

it.each(['native', 'scroll-area', 'root'] as const)(
'follows the entire highlighted range in a %s directory when only its end changes',
async kind => {
const { go, article, anchor, port, covered, expectRangeVisible, change } = setup(
kind,
undefined,
40,
)
article.value!.style.height = '80px'
await go(8)
await vi.waitFor(() => expect(covered()).toHaveLength(2))
await settle()
expectRangeVisible()
expect(port().scrollTop).toBe(0)
const scroll = vi.spyOn(port(), 'scrollTo')
const changes = change.mock.calls.length
const focused = document.activeElement
const pageTop = window.scrollY

article.value!.style.height = '160px'
await vi.waitFor(() => expect(covered()).toHaveLength(4))
await vi.waitFor(expectRangeVisible)
await vi.waitFor(() => {
const options = scroll.mock.lastCall?.[0] as ScrollToOptions | undefined
expect(options?.behavior).toBe('smooth')
expect(port().scrollTop).toBeCloseTo(options!.top!, 0)
})
expect(port().scrollTop).toBeGreaterThan(0)
expect(anchor.value?.current).toBe('follow-8')
expect(change).toHaveBeenCalledTimes(changes)
expect(article.value!.scrollTop).toBe(8 * 40)
expect(window.scrollY).toBe(pageTop)
expect(document.activeElement).toBe(focused)

const top = port().scrollTop
scroll.mockClear()
article.value!.style.height = '120px'
await vi.waitFor(() => expect(covered()).toHaveLength(3))
await settle()
expectRangeVisible()
expect(port().scrollTop).toBe(top)
expect(scroll).not.toHaveBeenCalled()
},
)

it('falls back to the first entry for an oversized range without alternating between its ends', async () => {
const { go, article, port, autoScroll, covered, expectVisible } = setup('native', false, 40)
port().style.height = '90px'
await go(28)
await vi.waitFor(() => expect(covered()).toHaveLength(8))
autoScroll.value = true
await vi.waitFor(expectVisible)
await settle()
const top = port().scrollTop
expect(top).toBeGreaterThan(0)
expect(covered().at(-1)!.getBoundingClientRect().bottom).toBeGreaterThan(
port().getBoundingClientRect().bottom,
)
const scroll = vi.spyOn(port(), 'scrollTo')
for (const height of [280, 320, 280, 320]) {
article.value!.style.height = `${height}px`
await vi.waitFor(() => expect(covered()).toHaveLength(height / 40))
await settle()
expectVisible()
expect(port().scrollTop).toBe(top)
}
port().style.width = '230px'
await settle()
expect(port().scrollTop).toBe(top)
expect(scroll).not.toHaveBeenCalled()
})

it('does not recenter visible entries or undo manual browsing of the directory', async () => {
const { go, port, items, anchor, change } = setup()
await vi.waitFor(() => expect(anchor.value?.current).toBe('follow-0'))
Expand Down
22 changes: 13 additions & 9 deletions packages/vue/src/components/anchor/Anchor.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts" generic="T extends AnchorItem = AnchorItem">
import { useTemplateRef, watch } from 'vue'
import { computed, useTemplateRef, watch } from 'vue'
import { cn } from '../../lib/cn'
import { useUiLocale } from '../../locale'
import Highlight from '../highlight/Highlight.vue'
Expand All @@ -11,12 +11,15 @@

defineOptions({ name: 'HnAnchor' })

const props = defineProps<{
items: T[]
label?: string
autoScroll?: boolean
class?: string
}>()
const props = withDefaults(
defineProps<{
items: T[]
label?: string
autoScroll?: boolean
class?: string
}>(),
{ autoScroll: true },
)

const emit = defineEmits<{ change: [current: string | undefined] }>()

Expand All @@ -26,9 +29,10 @@

const t = useUiLocale()

const { entries, visible, current, span, jump } = useAnchor(() => props.items)
const { entries, visible, covered, current, span, jump } = useAnchor(() => props.items)
const lastCovered = computed(() => covered.value.at(-1)?.id)
const root = useTemplateRef<HTMLElement>('root')
useAnchorFollow(root, current, () => props.autoScroll !== false)
useAnchorFollow(root, current, lastCovered, () => props.autoScroll)
watch(current, id => emit('change', id), { flush: 'post' })
defineExpose({ current })
</script>
Expand Down
21 changes: 18 additions & 3 deletions packages/vue/src/components/anchor/composables/useAnchorFollow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { prefersReducedMotion } from '../../../motion'
export function useAnchorFollow(
root: Readonly<Ref<HTMLElement | null>>,
current: Readonly<Ref<string | undefined>>,
lastCovered: Readonly<Ref<string | undefined>>,
enabled: () => boolean,
) {
const viewport = shallowRef<HTMLElement>()
let mounted = false
let frame = 0
let lastCurrent: string | undefined
let lastEnd: string | undefined

function findViewport(nav: HTMLElement, target: HTMLElement) {
const doc = nav.ownerDocument
Expand All @@ -36,14 +38,27 @@ export function useAnchorFollow(
if (!target || !link || !link.getClientRects().length) return
const port = findViewport(nav, target)
viewport.value = port
const changed = lastCurrent !== undefined && lastCurrent !== id
const endId = lastCovered.value ?? id
const changed = lastCurrent !== undefined && (lastCurrent !== id || lastEnd !== endId)
lastCurrent = id
lastEnd = endId
if (!port || port.clientHeight <= 0 || port.scrollHeight <= port.clientHeight) return

const box = port.getBoundingClientRect()
const row = link.getBoundingClientRect()
let row: Pick<DOMRect, 'top' | 'bottom' | 'height'> = link.getBoundingClientRect()
const scale = port.offsetHeight ? box.height / port.offsetHeight : 1
if (scale <= 0) return
if (endId !== id) {
const end = nav.querySelector<HTMLElement>(`a[href="#${CSS.escape(endId)}"]`)
if (end?.getClientRects().length) {
const bottom = end.getBoundingClientRect().bottom
const height = bottom - row.top
// Follow the full highlight only when it fits; oversized ranges keep
// following the first entry instead of alternating between both ends.
if (height >= row.height && height <= port.clientHeight * scale)
row = { top: row.top, bottom, height }
}
}
const top = box.top + port.clientTop * scale
const bottom = top + port.clientHeight * scale
if (row.top >= top && row.bottom <= bottom) return
Expand Down Expand Up @@ -81,7 +96,7 @@ export function useAnchorFollow(
if (mounted && !frame) frame = requestAnimationFrame(follow)
}

watch([root, current, enabled], schedule, { flush: 'post' })
watch([root, current, lastCovered, enabled], schedule, { flush: 'post' })
useResizeObserver(
() =>
[root.value, root.value?.parentElement, viewport.value].filter(
Expand Down
Loading
Loading