From 054721280e6fbc6f0eb5b5155dda7efc245939d8 Mon Sep 17 00:00:00 2001 From: ringyuki Date: Tue, 22 Sep 2026 17:50:10 +0800 Subject: [PATCH 1/3] fix: stabilize anchor following and docs state --- .changes/5e75d566.md | 6 + .changes/e6eba6e9.md | 6 + docs/app/components/docs/Banner.vue | 2 +- docs/content/en/components/anchor.md | 6 +- docs/content/zh-CN/components/anchor.md | 6 +- docs/i18n.test.ts | 15 +++ docs/i18n/locales/zh-CN.json | 5 + .../anchor/Anchor.follow.browser.test.ts | 113 ++++++++++++++++-- packages/vue/src/components/anchor/Anchor.vue | 22 ++-- .../anchor/composables/useAnchorFollow.ts | 21 +++- 10 files changed, 172 insertions(+), 30 deletions(-) create mode 100644 .changes/5e75d566.md create mode 100644 .changes/e6eba6e9.md create mode 100644 docs/i18n.test.ts diff --git a/.changes/5e75d566.md b/.changes/5e75d566.md new file mode 100644 index 00000000..6bb2d423 --- /dev/null +++ b/.changes/5e75d566.md @@ -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. diff --git a/.changes/e6eba6e9.md b/.changes/e6eba6e9.md new file mode 100644 index 00000000..79284a3c --- /dev/null +++ b/.changes/e6eba6e9.md @@ -0,0 +1,6 @@ +--- +type: fixed +scope: Anchor +--- + +Enable automatic directory scrolling by default when autoScroll is omitted, while preserving explicit opt-out. diff --git a/docs/app/components/docs/Banner.vue b/docs/app/components/docs/Banner.vue index bcbe94e2..bbe787a4 100644 --- a/docs/app/components/docs/Banner.vue +++ b/docs/app/components/docs/Banner.vue @@ -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 => { diff --git a/docs/content/en/components/anchor.md b/docs/content/en/components/anchor.md index cdcb7592..5d63a569 100644 --- a/docs/content/en/components/anchor.md +++ b/docs/content/en/components/anchor.md @@ -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. @@ -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} diff --git a/docs/content/zh-CN/components/anchor.md b/docs/content/zh-CN/components/anchor.md index 5cc71455..e6f932c1 100644 --- a/docs/content/zh-CN/components/anchor.md +++ b/docs/content/zh-CN/components/anchor.md @@ -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 项,可以独立滚动正文和目录,或关闭跟随作比较。 @@ -79,7 +79,7 @@ import { Anchor } from '@hina-ui/vue' | ------------ | --------- | ------------ | ---------------------------------- | | `items` | `T[]` | 必填 | 目录条目 | | `label` | `string` | 取自界面语言 | 导航地标的无障碍名 | -| `autoScroll` | `boolean` | `true` | 自动将当前项滚入目录自身的可视区域 | +| `autoScroll` | `boolean` | `true` | 跟随当前高亮段;整段过高时跟随段首 | | `class` | `string` | — | 追加到根元素的类 | ### AnchorItem {#item} diff --git a/docs/i18n.test.ts b/docs/i18n.test.ts new file mode 100644 index 00000000..87720191 --- /dev/null +++ b/docs/i18n.test.ts @@ -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]), + [], + ) +}) diff --git a/docs/i18n/locales/zh-CN.json b/docs/i18n/locales/zh-CN.json index f134df50..6de61454 100644 --- a/docs/i18n/locales/zh-CN.json +++ b/docs/i18n/locales/zh-CN.json @@ -385,6 +385,7 @@ }, "names": { "accordion": "折叠列表", + "affix": "吸附", "alert": "消息条", "alert-dialog": "确认对话框", "anchor": "页内目录", @@ -400,6 +401,7 @@ "card": "卡片", "calendar": "日历", "callout": "标注", + "carousel": "轮播", "center": "居中", "checkbox": "复选框", "checkbox-group": "复选框组", @@ -450,8 +452,10 @@ "listbox": "列表框", "loading-overlay": "加载遮罩", "mark": "标记", + "masonry": "瀑布流", "menubar": "菜单栏", "meter-group": "占比条", + "month-grid": "月份日历", "multi-select": "多选选择器", "multi-combobox": "多选组合框", "nav-link": "导航项", @@ -467,6 +471,7 @@ "prev-next": "上下页", "progress": "进度条", "prose": "富文本", + "qr-code": "二维码", "radio-group": "单选框组", "range-calendar": "范围日历", "range-slider": "范围滑块", diff --git a/packages/vue/src/components/anchor/Anchor.follow.browser.test.ts b/packages/vue/src/components/anchor/Anchor.follow.browser.test.ts index ce282e09..9e817f2d 100644 --- a/packages/vue/src/components/anchor/Anchor.follow.browser.test.ts +++ b/packages/vue/src/components/anchor/Anchor.follow.browser.test.ts @@ -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}`, @@ -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]', }) @@ -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', @@ -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('a[aria-current]')! - const expectVisible = () => { + const covered = () => Array.from(nav().querySelectorAll('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')) @@ -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')) diff --git a/packages/vue/src/components/anchor/Anchor.vue b/packages/vue/src/components/anchor/Anchor.vue index 6b17f0ab..ab92f4a7 100644 --- a/packages/vue/src/components/anchor/Anchor.vue +++ b/packages/vue/src/components/anchor/Anchor.vue @@ -1,5 +1,5 @@ diff --git a/packages/vue/src/components/anchor/composables/useAnchorFollow.ts b/packages/vue/src/components/anchor/composables/useAnchorFollow.ts index 21c89211..ac36c65d 100644 --- a/packages/vue/src/components/anchor/composables/useAnchorFollow.ts +++ b/packages/vue/src/components/anchor/composables/useAnchorFollow.ts @@ -5,12 +5,14 @@ import { prefersReducedMotion } from '../../../motion' export function useAnchorFollow( root: Readonly>, current: Readonly>, + lastCovered: Readonly>, enabled: () => boolean, ) { const viewport = shallowRef() let mounted = false let frame = 0 let lastCurrent: string | undefined + let lastEnd: string | undefined function findViewport(nav: HTMLElement, target: HTMLElement) { const doc = nav.ownerDocument @@ -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 = link.getBoundingClientRect() const scale = port.offsetHeight ? box.height / port.offsetHeight : 1 if (scale <= 0) return + if (endId !== id) { + const end = nav.querySelector(`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 @@ -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( From 14c42370a3fb0ec675dd7b862f27aa9cabf4ba4c Mon Sep 17 00:00:00 2001 From: ringyuki Date: Sun, 27 Sep 2026 14:57:34 +0800 Subject: [PATCH 2/3] fix: prevent lightbox SSR memory leaks --- .changes/bf988426.md | 6 +++ .../components/lightbox/Lightbox.ssr.test.ts | 44 +++++++++++++++++++ .../lightbox/composables/useLightboxHint.ts | 7 ++- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 .changes/bf988426.md create mode 100644 packages/vue/src/components/lightbox/Lightbox.ssr.test.ts diff --git a/.changes/bf988426.md b/.changes/bf988426.md new file mode 100644 index 00000000..d2d8cb9c --- /dev/null +++ b/.changes/bf988426.md @@ -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. diff --git a/packages/vue/src/components/lightbox/Lightbox.ssr.test.ts b/packages/vue/src/components/lightbox/Lightbox.ssr.test.ts new file mode 100644 index 00000000..d7ae518e --- /dev/null +++ b/packages/vue/src/components/lightbox/Lightbox.ssr.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSSRApp, h } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { frame } from 'motion-v' +import Image from '../image/Image.vue' +import ImageGroup from '../image/ImageGroup.vue' +import Lightbox from './Lightbox.vue' + +afterEach(() => vi.restoreAllMocks()) + +describe('Image preview SSR', () => { + const items = [{ id: 'photo', src: '/photo.webp', alt: 'Photo' }] + + it.each([ + ['Image', () => h(Image, { src: '/photo.webp', alt: 'Photo', lazy: false })], + [ + 'Image preview', + () => h(Image, { src: '/photo.webp', alt: 'Photo', lazy: false, preview: true }), + ], + [ + 'ImageGroup', + () => + h(ImageGroup, null, { + default: () => h(Image, { src: '/photo.webp', alt: 'Photo', lazy: false, preview: true }), + }), + ], + ['Lightbox closed', () => h(Lightbox, { items })], + ['Lightbox open', () => h(Lightbox, { items, open: true })], + ])('%s does not retain requests in the animation frame queue', async (_name, render) => { + const read = vi.spyOn(frame, 'read') + const preRender = vi.spyOn(frame, 'preRender') + + for (let request = 0; request < 3; request++) { + const html = await renderToString(createSSRApp({ render })) + if (_name.startsWith('Image')) { + expect(html).toContain('src="/photo.webp"') + expect(html).toContain('alt="Photo"') + } + } + + expect(read).not.toHaveBeenCalled() + expect(preRender).not.toHaveBeenCalled() + }) +}) diff --git a/packages/vue/src/components/lightbox/composables/useLightboxHint.ts b/packages/vue/src/components/lightbox/composables/useLightboxHint.ts index 2ed70f70..234251c8 100644 --- a/packages/vue/src/components/lightbox/composables/useLightboxHint.ts +++ b/packages/vue/src/components/lightbox/composables/useLightboxHint.ts @@ -1,4 +1,4 @@ -import { useTransform } from 'motion-v' +import { useMotionValue, useTransform } from 'motion-v' import type { useLightboxMotion } from './useLightboxMotion' import type { useLightboxFrames } from './useLightboxFrames' import type { useLightboxPaging } from './useLightboxPaging' @@ -10,6 +10,11 @@ export function useLightboxHint(options: { paging: ReturnType current: () => LightboxItem | undefined }) { + // useTransform's subscription refresh queues frame callbacks even during SSR. + // Node never drains that queue, retaining the request through these closures. + // The hint is only rendered inside the client-mounted lightbox. + if (typeof window === 'undefined') return { x: useMotionValue(0), y: useMotionValue(0) } + const { motion, frames, paging } = options function corner() { From ceaaf735021eaac3c82a1652e2fe05fcf6108bb8 Mon Sep 17 00:00:00 2001 From: ringyuki Date: Sun, 27 Sep 2026 14:57:38 +0800 Subject: [PATCH 3/3] fix: constrain long titles in prev-next navigation --- .changes/65193d30.md | 6 ++ .../prev-next/PrevNext.browser.test.ts | 70 +++++++++++++++++++ .../vue/src/components/prev-next/PrevNext.vue | 2 +- .../src/components/prev-next/PrevNextLink.vue | 2 +- 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 .changes/65193d30.md diff --git a/.changes/65193d30.md b/.changes/65193d30.md new file mode 100644 index 00000000..3572db9d --- /dev/null +++ b/.changes/65193d30.md @@ -0,0 +1,6 @@ +--- +type: fixed +scope: PrevNext +--- + +Constrain the narrow-screen grid and title wrapper so long truncated titles stay inside navigation cards. diff --git a/packages/vue/src/components/prev-next/PrevNext.browser.test.ts b/packages/vue/src/components/prev-next/PrevNext.browser.test.ts index 936be29d..d77096f3 100644 --- a/packages/vue/src/components/prev-next/PrevNext.browser.test.ts +++ b/packages/vue/src/components/prev-next/PrevNext.browser.test.ts @@ -4,6 +4,7 @@ import { mount, type VueWrapper } from '@vue/test-utils' import { defineComponent, h } from 'vue' import PrevNext from './PrevNext.vue' import PrevNextLink from './PrevNextLink.vue' +import Text from '../text/Text.vue' import '../../../test/browser.css' let mounted: VueWrapper[] = [] @@ -63,4 +64,73 @@ describe('prev-next · 上下页导航', () => { await userEvent.keyboard('{Tab}') expect(document.activeElement).toBe(prev) }) + + it.each( + [414, 900].flatMap(viewport => + (['ltr', 'rtl'] as const).flatMap(dir => + [false, true].map(single => ({ viewport, dir, single })), + ), + ), + )( + 'keeps long titles inside cards ($viewport px, $dir, single=$single)', + async ({ viewport, dir, single }) => { + await page.viewport(viewport, 800) + const host = document.createElement('div') + host.style.cssText = 'width: calc(100% - 32px); max-width: 720px; margin-inline: auto' + document.body.appendChild(host) + const directions = single ? (['next'] as const) : (['prev', 'next'] as const) + const title = 'ChapterWithoutWordBreaks'.repeat(16) + const w = mount( + defineComponent({ + setup: () => () => + h(PrevNext, { dir }, () => + directions.map(direction => + h(PrevNextLink, { direction, href: `#${direction}` }, () => + h(Text, { truncate: true }, () => title), + ), + ), + ), + }), + { attachTo: host }, + ) + mounted.push(w) + const nav = w.get('nav').element as HTMLElement + const bounds = nav.getBoundingClientRect() + const links = w.findAll('a').map(link => link.element as HTMLElement) + const gap = parseFloat(getComputedStyle(nav).columnGap) + const expectedWidth = viewport < 640 ? bounds.width : (bounds.width - gap) / 2 + + for (const link of links) { + const card = link.getBoundingClientRect() + expect(Math.abs(card.width - expectedWidth)).toBeLessThan(1) + expect(card.left).toBeGreaterThanOrEqual(bounds.left - 1) + expect(card.right).toBeLessThanOrEqual(bounds.right + 1) + const text = link.querySelector('p')! + const content = text.parentElement! + const style = getComputedStyle(link) + const left = card.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) + const right = + card.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight) + expect(content.getBoundingClientRect().left).toBeGreaterThanOrEqual(left - 1) + expect(content.getBoundingClientRect().right).toBeLessThanOrEqual(right + 1) + expect(text.scrollWidth).toBeGreaterThan(text.clientWidth) + expect(getComputedStyle(text).textOverflow).toBe('ellipsis') + expect(link.scrollWidth).toBeLessThanOrEqual(link.clientWidth + 1) + expect(text.textContent).toBe(title) + } + if (links.length === 2) { + const [prev, next] = links.map(link => link.getBoundingClientRect()) + if (viewport < 640) expect(next!.top).toBeGreaterThan(prev!.bottom) + else expect(next!.top).toBe(prev!.top) + } else if (viewport >= 640) { + const next = links[0]!.getBoundingClientRect() + expect( + Math.abs(dir === 'ltr' ? next.right - bounds.right : next.left - bounds.left), + ).toBeLessThan(1) + } + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual( + document.documentElement.clientWidth, + ) + }, + ) }) diff --git a/packages/vue/src/components/prev-next/PrevNext.vue b/packages/vue/src/components/prev-next/PrevNext.vue index 432d7953..8e3d72c7 100644 --- a/packages/vue/src/components/prev-next/PrevNext.vue +++ b/packages/vue/src/components/prev-next/PrevNext.vue @@ -15,7 +15,7 @@