diff --git a/__tests__/e2e/sidebar.test.ts b/__tests__/e2e/sidebar.test.ts new file mode 100644 index 000000000000..288b15c83c49 --- /dev/null +++ b/__tests__/e2e/sidebar.test.ts @@ -0,0 +1,36 @@ +describe('sidebar', () => { + beforeAll(async () => { + await goto('/frontmatter/multiple-levels-outline') + }) + + test('collapsible group renders a heading and a single toggle button', async () => { + const group = page.locator('.VPSidebarItem.level-0.collapsible').first() + const caret = group.locator('.caret').first() + + expect(await page.locator('.VPSidebarItem [role="button"]').count()).toBe(0) + expect(await caret.evaluate((el) => el.tagName)).toBe('BUTTON') + expect(await caret.getAttribute('aria-expanded')).toBe('true') + }) + + test('group toggles with keyboard, caret and heading', async () => { + const group = page.locator('.VPSidebarItem.level-0.collapsible').first() + const caret = group.locator('.caret').first() + const isCollapsed = () => + group.evaluate((el) => el.classList.contains('collapsed')) + + await caret.focus() + await page.keyboard.press('Enter') + expect(await isCollapsed()).toBe(true) + expect(await caret.getAttribute('aria-expanded')).toBe('false') + + await page.keyboard.press('Space') + expect(await isCollapsed()).toBe(false) + expect(await caret.getAttribute('aria-expanded')).toBe('true') + + await caret.click() + expect(await isCollapsed()).toBe(true) + + await group.locator('.text').first().click() + expect(await isCollapsed()).toBe(false) + }) +}) diff --git a/__tests__/unit/shared/shared.test.ts b/__tests__/unit/shared/shared.test.ts new file mode 100644 index 000000000000..77826db31746 --- /dev/null +++ b/__tests__/unit/shared/shared.test.ts @@ -0,0 +1,56 @@ +import { mergeHead, type HeadConfig } from 'shared/shared' + +describe('shared/shared', () => { + describe('mergeHead', () => { + test('replaces meta tags with the same key in place', () => { + expect( + mergeHead( + [ + ['meta', { property: 'og:image', content: '/site.png' }], + ['meta', { name: 'keywords', content: 'site' }] + ], + [['meta', { content: '/page.png', property: 'og:image' }]] + ) + ).toEqual([ + ['meta', { content: '/page.png', property: 'og:image' }], + ['meta', { name: 'keywords', content: 'site' }] + ]) + }) + + test('ignores content when keying meta tags', () => { + const head: HeadConfig[] = [ + ['meta', { content: 'a', name: 'name1' }], + ['meta', { content: 'a', name: 'name2' }] + ] + expect(mergeHead(head)).toEqual(head) + }) + + test('keys any element by id regardless of attribute order', () => { + expect( + mergeHead( + [ + ['meta', { name: 'author', content: 'a', id: 'author-a' }], + ['meta', { name: 'author', content: 'b', id: 'author-b' }], + ['script', { id: 'sw' }, 'old'] + ], + [ + ['meta', { id: 'author-a', name: 'author', content: 'c' }], + ['script', { id: 'sw' }, 'new'] + ] + ) + ).toEqual([ + ['meta', { id: 'author-a', name: 'author', content: 'c' }], + ['meta', { name: 'author', content: 'b', id: 'author-b' }], + ['script', { id: 'sw' }, 'new'] + ]) + }) + + test('appends elements without a key', () => { + const head: HeadConfig[] = [ + ['link', { rel: 'stylesheet', href: '/a.css' }], + ['link', { rel: 'stylesheet', href: '/a.css' }] + ] + expect(mergeHead(head, head)).toEqual([...head, ...head]) + }) + }) +}) diff --git a/docs/en/reference/default-theme-search.md b/docs/en/reference/default-theme-search.md index 66bc0693d9c4..d383288e0902 100644 --- a/docs/en/reference/default-theme-search.md +++ b/docs/en/reference/default-theme-search.md @@ -27,11 +27,11 @@ Example result: Alternatively, you can use [Algolia DocSearch](#algolia-search) or some community plugins like: -- -- -- -- -- +- +- +- + + ### i18n {#local-search-i18n} diff --git a/docs/en/reference/frontmatter-config.md b/docs/en/reference/frontmatter-config.md index 8960bfe23584..42f57f4a40a4 100644 --- a/docs/en/reference/frontmatter-config.md +++ b/docs/en/reference/frontmatter-config.md @@ -63,7 +63,7 @@ description: VitePress - Type: `HeadConfig[]` -Specify extra head tags to be injected for the current page. Will be appended after head tags injected by site-level config. +Specify extra head tags to be injected for the current page. They are [merged](./site-config#head) with the head tags injected by site-level config. ```yaml --- diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index a9d9c4f178fe..d79eb6378346 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -248,6 +248,13 @@ type HeadConfig = | [string, Record, string] ``` +Head entries from the site config, [locale config](../guide/i18n), [directory-level config](#directory-level-overrides), [frontmatter](./frontmatter-config#head) and [`transformHead`](#transformhead) are merged in that order. A later entry replaces an earlier one with the same key instead of being appended: + +- Any element with an `id` attribute is keyed by its `id`. +- A `meta` element without an `id` is keyed by its first attribute other than `content` (e.g. `name`, `property`, `http-equiv`) and that attribute's value. + +Other elements are never deduplicated. To render multiple `meta` tags that would share a key, like several ``, give each of them a unique `id`. + #### Example: Adding a favicon ```ts diff --git a/src/client/theme-default/components/VPSidebarItem.vue b/src/client/theme-default/components/VPSidebarItem.vue index 6dad9f58e6b4..fe268a63ee5b 100644 --- a/src/client/theme-default/components/VPSidebarItem.vue +++ b/src/client/theme-default/components/VPSidebarItem.vue @@ -19,19 +19,16 @@ const { toggle } = useSidebarItemControl(computed(() => props.item)) -const sectionTag = computed(() => (hasChildren.value ? 'section' : `div`)) - const linkTag = computed(() => (isLink.value ? 'a' : 'div')) -const textTag = computed(() => { - return !hasChildren.value - ? 'p' - : props.depth + 2 === 7 - ? 'p' - : `h${props.depth + 2}` -}) +const textTag = computed(() => + hasChildren.value && props.depth < 5 ? `h${props.depth + 2}` : 'p' +) -const itemRole = computed(() => (isLink.value ? undefined : 'button')) +// a section needs a heading +const sectionTag = computed(() => + props.item.text && textTag.value !== 'p' ? 'section' : 'div' +) const classes = computed(() => [ [`level-${props.depth}`], @@ -42,31 +39,14 @@ const classes = computed(() => [ { 'has-active': hasActiveLink.value } ]) -function onItemInteraction(e: MouseEvent | Event) { - if ('key' in e && e.key !== 'Enter') { - return - } +function onItemClick() { !props.item.link && toggle() } - -function onCaretClick() { - props.item.link && toggle() -}