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
61 changes: 61 additions & 0 deletions test/components/ModalDialogClose.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import ModalDialogClose from '../../src/runtime/components/ModalDialogClose.vue'
import Modal from '../../src/runtime/components/Modal.vue'
import Button from '../../src/runtime/components/Button.vue'

/**
* A nineteen-line passthrough over reka-ui's `DialogClose`, used once — by
* `SidebarLayout`, to close the mobile sidebar. It renders `as-child`, so it
* contributes no element of its own, and everything depends on it forwarding
* both the slot and the close behaviour to whatever it wraps.
*
* Mounted inside a real `Modal`: on its own, `DialogClose` has no dialog to
* close and the test would be asserting that nothing happens. `open` is bound
* rather than passed as a literal `true`, because a literal one is never
* written back and the dialog would stay open however well the click worked.
*/
describe('ModalDialogClose', () => {
const mountInModal = () => {
const open = ref(true)

return mountSuspended({
components: { B24Modal: Modal, B24Button: Button, B24ModalDialogClose: ModalDialogClose },
setup: () => ({ open }),
template: `
<B24Modal v-model:open="open" :portal="false" title="Title">
<template #body>
<B24ModalDialogClose>
<B24Button label="Close it" />
</B24ModalDialogClose>
</template>
</B24Modal>
`
})
}

/** The dialog renders its own close button too; ours is the one with a label. */
const ourButton = (wrapper: Awaited<ReturnType<typeof mountInModal>>) =>
wrapper.findAll('button').find(button => button.text().includes('Close it'))!

it('renders its child and contributes no element of its own', async () => {
const wrapper = await mountInModal()

// `as-child` means the child *is* the trigger — reka-ui puts the dialog
// wiring onto the button rather than wrapping it in an element.
expect(ourButton(wrapper).exists()).toBe(true)
expect(wrapper.html()).not.toContain('data-slot="modalDialogClose"')
})

it('closes the dialog when its child is activated', async () => {
const wrapper = await mountInModal()

expect(wrapper.find('[data-slot="content"]').exists()).toBe(true)

await ourButton(wrapper).trigger('click')
await new Promise(resolve => setTimeout(resolve, 50))

expect(wrapper.find('[data-slot="content"]').exists()).toBe(false)
})
})
100 changes: 100 additions & 0 deletions test/utils/content-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import type { ContentNavigationItem } from '@nuxt/content'
import { mapContentNavigation, mapContentNavigationItem } from '../../src/runtime/utils/content'

/**
* Turns `@nuxt/content`'s navigation tree into the item shape the navigation
* components take — which is to say, it builds the sidebar of any docs site
* built on this kit. Nothing exercised it before.
*
* The rename is the whole of it: `title` becomes `label` and `path` becomes
* `to`, because that is what `NavigationMenu` and friends read. Get it wrong
* and the sidebar renders a column of blank, unclickable rows.
*/
describe('mapContentNavigationItem', () => {
const entry = (over: Partial<ContentNavigationItem> = {}) =>
({ title: 'Getting started', path: '/docs/start', ...over }) as ContentNavigationItem

it('renames title to label and path to to', () => {
expect(mapContentNavigationItem(entry())).toMatchObject({
label: 'Getting started',
to: '/docs/start'
})
})

it('drops the original keys rather than carrying both', () => {
const link = mapContentNavigationItem(entry())

expect(link).not.toHaveProperty('title')
expect(link).not.toHaveProperty('path')
})

it('carries every other key through untouched', () => {
const link = mapContentNavigationItem(entry({ icon: 'i-lucide-home', badge: 'new' } as any))

expect(link).toMatchObject({ icon: 'i-lucide-home', badge: 'new' })
})

it('takes the label from labelAttribute when one is given', () => {
const link = mapContentNavigationItem(entry({ navTitle: 'Start here' } as any), { labelAttribute: 'navTitle' })

// `title` is no longer the mapped key, so it stays under its own name.
expect(link).toMatchObject({ label: 'Start here', title: 'Getting started' })
})

it('skips falsy values, so an empty title does not produce an empty label', () => {
const link = mapContentNavigationItem(entry({ title: '' }))

expect(link).not.toHaveProperty('label')
})

describe('children', () => {
const nested = entry({
children: [entry({ title: 'Install', path: '/docs/install', children: [entry({ title: 'CLI', path: '/docs/install/cli' })] })]
})

it('recurses, renaming at every level', () => {
const link = mapContentNavigationItem(nested)

expect(link.children?.[0]).toMatchObject({ label: 'Install', to: '/docs/install' })
expect((link.children?.[0] as any).children[0]).toMatchObject({ label: 'CLI', to: '/docs/install/cli' })
})

it('always sets children, so a leaf is an empty array rather than absent', () => {
expect(mapContentNavigationItem(entry()).children).toEqual([])
})

it('stops at the depth `deep` names', () => {
const link = mapContentNavigationItem(nested, { deep: 1 })

expect(link.children).toHaveLength(1)
// Depth 1 is the last level walked; its own children are cut.
expect(link.children?.[0]?.children).toEqual([])
})

it('flattens to a list of roots at deep: 0', () => {
expect(mapContentNavigationItem(nested, { deep: 0 }).children).toEqual([])
})
})
})

describe('mapContentNavigation', () => {
it('maps a whole tree and keeps its order', () => {
const result = mapContentNavigation([
{ title: 'One', path: '/one' },
{ title: 'Two', path: '/two' }
] as ContentNavigationItem[])

expect(result.map(item => item.label)).toEqual(['One', 'Two'])
expect(result.map(item => item.to)).toEqual(['/one', '/two'])
})

it('passes its options down', () => {
const result = mapContentNavigation(
[{ navTitle: 'Renamed', title: 'Original', path: '/x' }] as unknown as ContentNavigationItem[],
{ labelAttribute: 'navTitle' }
)

expect(result[0]).toMatchObject({ label: 'Renamed' })
})
})
48 changes: 48 additions & 0 deletions test/utils/link-partial-query.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { isPartiallyEqual } from '../../src/runtime/utils/link'

/**
* What `exactQuery: 'partial'` means for a link's active state.
*
* `Link` calls this with the link's own query and the current route's query, in
* that order. The current route carries whatever else the app put in the URL —
* a search term, a page number, a tracking parameter — and a link that declares
* `?tab=general` should stay highlighted through all of it. So keys the second
* object adds are ignored, and everything the link declares still has to match.
*
* Nothing exercised it before: `utils/link.ts` sat at 44% statements and 0%
* branches, and the only visible symptom of it breaking is a navigation item
* that highlights when it should not, or stops highlighting when it should.
*/
describe('isPartiallyEqual', () => {
it('ignores keys the current route adds', () => {
expect(isPartiallyEqual({ tab: 'general' }, { tab: 'general', page: '2', q: 'term' })).toBe(true)
})

it('refuses when a declared key holds a different value', () => {
expect(isPartiallyEqual({ tab: 'general' }, { tab: 'billing', page: '2' })).toBe(false)
})

it('refuses when a declared key is missing from the route', () => {
// The asymmetry that makes this "partial" rather than "subset either way":
// extra keys on the right are forgiven, missing ones are not.
expect(isPartiallyEqual({ tab: 'general' }, { page: '2' })).toBe(false)
})

it('matches two empty queries', () => {
expect(isPartiallyEqual({}, {})).toBe(true)
})

it('matches an empty declaration against any route', () => {
expect(isPartiallyEqual({}, { page: '2' })).toBe(true)
})

it('compares values rather than their string forms', () => {
expect(isPartiallyEqual({ page: 2 }, { page: '2' })).toBe(false)
})

it('handles a repeated query parameter, which vue-router gives as an array', () => {
expect(isPartiallyEqual({ tag: ['a', 'b'] }, { tag: ['a', 'b'], page: '2' })).toBe(true)
expect(isPartiallyEqual({ tag: ['a', 'b'] }, { tag: ['a'], page: '2' })).toBe(false)
})
})
89 changes: 89 additions & 0 deletions test/utils/overlay.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from 'vitest'
import type { PointerDownOutsideEvent } from 'reka-ui'
import { pointerDownOutside } from '../../src/runtime/utils/overlay'

/**
* What decides whether a click beside a `Modal`, `Drawer` or `Popover` closes
* it. Both of its branches exist because of a case where the honest answer —
* "the pointer went down outside, so close" — is wrong, and both are invisible
* until they misfire on a real user: an overlay that will not close, or one
* that closes while its scrollbar is being dragged.
*/
describe('pointerDownOutside', () => {
const event = (target: Partial<HTMLElement>, offsets: { offsetX?: number, offsetY?: number } = {}) => {
const preventDefault = vi.fn()
return {
event: { detail: { originalEvent: { target, offsetX: 0, offsetY: 0, ...offsets } }, preventDefault } as unknown as PointerDownOutsideEvent,
preventDefault
}
}

const connected = (over: Partial<HTMLElement> = {}) =>
({ isConnected: true, clientWidth: 100, clientHeight: 100, ...over }) as Partial<HTMLElement>

it('closes on an ordinary click outside', () => {
const { event: e, preventDefault } = event(connected())

pointerDownOutside(e)

expect(preventDefault).not.toHaveBeenCalled()
})

describe('a target no longer in the document', () => {
// On touch, reka-ui defers the dispatch to the click event. If the element
// went away in between — a toast dismissing itself is the case this was
// written for — the overlay would take that as a click outside and close.
it('does not close', () => {
const { event: e, preventDefault } = event({ isConnected: false })

pointerDownOutside(e)

expect(preventDefault).toHaveBeenCalledOnce()
})

it('does not close when there is no target at all', () => {
const { event: e, preventDefault } = event(null as unknown as Partial<HTMLElement>)

pointerDownOutside(e)

expect(preventDefault).toHaveBeenCalledOnce()
})
})

describe('scrollable mode', () => {
// A scrollbar is painted outside the element's client box, so dragging one
// reads as a click outside. Only checked in scrollable mode: elsewhere the
// overlay has no scrollbar of its own to hit.
it('does not close when the pointer lands past the right edge', () => {
const { event: e, preventDefault } = event(connected(), { offsetX: 108 })

pointerDownOutside(e, { scrollable: true })

expect(preventDefault).toHaveBeenCalledOnce()
})

it('does not close when the pointer lands past the bottom edge', () => {
const { event: e, preventDefault } = event(connected(), { offsetY: 108 })

pointerDownOutside(e, { scrollable: true })

expect(preventDefault).toHaveBeenCalledOnce()
})

it('still closes on a click inside the client box', () => {
const { event: e, preventDefault } = event(connected(), { offsetX: 40, offsetY: 40 })

pointerDownOutside(e, { scrollable: true })

expect(preventDefault).not.toHaveBeenCalled()
})

it('ignores the scrollbar edges when scrollable is off', () => {
const { event: e, preventDefault } = event(connected(), { offsetX: 108, offsetY: 108 })

pointerDownOutside(e)

expect(preventDefault).not.toHaveBeenCalled()
})
})
})
Loading