From 5a4700a068ed300b2b1093e8bd332c3f379025d7 Mon Sep 17 00:00:00 2001 From: Mister-Hope Date: Wed, 17 Apr 2024 13:11:59 +0800 Subject: [PATCH] feat(client): add AutoLink component --- e2e/docs/components/auto-link.md | 61 ++++++ e2e/tests/components/auto-link.spec.ts | 41 ++++ packages/client/src/components/AutoLink.ts | 227 +++++++++++++++++++++ packages/client/src/components/index.ts | 1 + 4 files changed, 330 insertions(+) create mode 100644 e2e/docs/components/auto-link.md create mode 100644 e2e/tests/components/auto-link.spec.ts create mode 100644 packages/client/src/components/AutoLink.ts diff --git a/e2e/docs/components/auto-link.md b/e2e/docs/components/auto-link.md new file mode 100644 index 0000000000..08cdbc9e86 --- /dev/null +++ b/e2e/docs/components/auto-link.md @@ -0,0 +1,61 @@ +# AutoLink + + + + + +
+ + +
+ + diff --git a/e2e/tests/components/auto-link.spec.ts b/e2e/tests/components/auto-link.spec.ts new file mode 100644 index 0000000000..653df632e9 --- /dev/null +++ b/e2e/tests/components/auto-link.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test' +import { BASE } from '../../utils/env' + +test.beforeEach(async ({ page }) => { + await page.goto('components/auto-link.html') +}) + +test('should render route-link correctly', async ({ page }) => { + for (const el of await page + .locator('.e2e-theme-content #route-link a') + .all()) { + await expect(el).toHaveAttribute('class', /route-link/) + } +}) + +test('should render external-link correctly', async ({ page }) => { + for (const el of await page + .locator('.e2e-theme-content #external-link a') + .all()) { + await expect(el).toHaveAttribute('class', /external-link/) + } +}) + +test('should render config correctly', async ({ page }) => { + const locator = page.locator('.e2e-theme-content #config a') + + await expect(await locator.nth(0)).toHaveText('text1') + await expect(await locator.nth(0)).toHaveAttribute('href', BASE) + await expect(await locator.nth(0)).toHaveAttribute('aria-label', 'label') + + await expect(await locator.nth(1)).toHaveText('text2') + await expect(await locator.nth(1)).toHaveAttribute( + 'href', + 'https://example.com/test/', + ) + await expect(await locator.nth(1)).toHaveAttribute('target', '_blank') + await expect(await locator.nth(1)).toHaveAttribute( + 'rel', + 'noopener noreferrer', + ) +}) diff --git a/packages/client/src/components/AutoLink.ts b/packages/client/src/components/AutoLink.ts new file mode 100644 index 0000000000..bfe9cd7d39 --- /dev/null +++ b/packages/client/src/components/AutoLink.ts @@ -0,0 +1,227 @@ +import { isLinkWithProtocol } from '@vuepress/shared' +import type { SlotsType, VNode } from 'vue' +import { computed, defineComponent, h } from 'vue' +import { useRoute } from 'vue-router' +import { useSiteData } from '../composables/index.js' +import { RouteLink } from './RouteLink.js' + +export interface AutoLinkConfig { + /** + * Text of item + * + * 项目文字 + */ + text: string + + /** + * Aria label of item + * + * 项目无障碍标签 + */ + ariaLabel?: string + + /** + * Link of item + * + * 当前页面链接 + */ + link: string + + /** + * Rel of `` tag + * + * `` 标签的 `rel` 属性 + */ + rel?: string + + /** + * Target of `` tag + * + * `` 标签的 `target` 属性 + */ + target?: string + + /** + * Regexp mode to be active + * + * 匹配激活的正则表达式 + */ + activeMatch?: string +} + +export const AutoLink = defineComponent({ + name: 'AutoLink', + + props: { + /** + * Text of item + * + * 项目文字 + */ + text: { + type: String, + required: true, + }, + + /** + * Link of item + * + * 当前页面链接 + */ + link: { + type: String, + required: true, + }, + + /** + * Aria label of item + * + * 项目无障碍标签 + */ + ariaLabel: { + type: String, + default: '', + }, + + /** + * Rel of `` tag + * + * `` 标签的 `rel` 属性 + */ + rel: { + type: String, + default: '', + }, + + /** + * Target of `` tag + * + * `` 标签的 `target` 属性 + */ + target: { + type: String, + default: '', + }, + + /** + * Whether it's active only when exact match + * + * 是否当恰好匹配时激活 + */ + exact: Boolean, + + /** + * Regexp mode to be active + * + * @description has higher priority than exact + * + * 匹配激活的正则表达式 + * + * @description 比 exact 的优先级更高 + */ + activeMatch: { + type: [String, RegExp], + default: '', + }, + }, + + slots: Object as SlotsType<{ + default?: () => VNode[] | VNode + before?: () => VNode[] | VNode | null + after?: () => VNode[] | VNode | null + }>, + + setup(props, { slots }) { + const route = useRoute() + const siteData = useSiteData() + + // If the link has non-http protocol + const withProtocol = computed(() => isLinkWithProtocol(props.link)) + + // Resolve the `target` attr + const linkTarget = computed( + () => props.target || (withProtocol.value ? '_blank' : undefined), + ) + + // If the `target` attr is "_blank" + const isBlankTarget = computed(() => linkTarget.value === '_blank') + + // Whether the link is internal + const isInternal = computed( + () => !withProtocol.value && !isBlankTarget.value, + ) + + // Resolve the `rel` attr + const linkRel = computed( + () => props.rel || (isBlankTarget.value ? 'noopener noreferrer' : null), + ) + + // Resolve the `aria-label` attr + const linkAriaLabel = computed(() => props.ariaLabel ?? props.text) + + // Should be active when current route is a subpath of this link + const shouldBeActiveInSubpath = computed(() => { + // Should not be active in `exact` mode + if (props.exact) return false + + const localePaths = Object.keys(siteData.value.locales) + + return localePaths.length + ? // Check all the locales + localePaths.every((key) => key !== props.link) + : // Check root + props.link !== '/' + }) + + // If this link is active + const isActive = computed(() => { + if (!isInternal.value) return false + + if (props.activeMatch) + return ( + props.activeMatch instanceof RegExp + ? props.activeMatch + : new RegExp(props.activeMatch, 'u') + ).test(route.path) + + // If this link is active in subpath + if (shouldBeActiveInSubpath.value) + return route.path.startsWith(props.link) + + return route.path === props.link + }) + + return (): VNode => { + const { before, after, default: defaultSlot } = slots + + const content = defaultSlot?.() || [ + before ? before() : null, + props.text, + after?.(), + ] + + return isInternal.value + ? h( + RouteLink, + { + 'class': 'auto-link', + 'to': props.link, + 'active': isActive.value, + 'aria-label': linkAriaLabel.value, + }, + () => content, + ) + : h( + 'a', + { + 'class': 'auto-link external-link', + 'href': props.link, + 'rel': linkRel.value, + 'target': linkTarget.value, + 'aria-label': linkAriaLabel.value, + }, + content, + ) + } + }, +}) diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index f4bc111a81..72d5746a89 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -1,3 +1,4 @@ +export * from './AutoLink.js' export * from './ClientOnly.js' export * from './Content.js' export * from './RouteLink.js'