Slideover
A dialog that slides in from any side of the screen.
Demo
Related Components
This requires the following components to be installed:
Related Composables
This requires the following composables to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<script lang="ts">
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { ComponentConfig } from '@/ui/utils/utils'
import type { DialogContentEmits, DialogContentProps, DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import Button from '@/ui/components/Button.vue'
import { usePortal } from '@/ui/composables/usePortal'
import theme from '@/ui/theme/slideover'
import { reactivePick } from '@vueuse/shared'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, useForwardPropsEmits, VisuallyHidden } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'
type Slideover = ComponentConfig<typeof theme>
export interface SlideoverProps extends DialogRootProps {
title?: string
description?: string
content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DialogContentEmits>>
overlay?: boolean
transition?: boolean
side?: Slideover['variants']['side']
portal?: boolean | string | HTMLElement
close?: boolean | Partial<ButtonProps>
closeIcon?: string
dismissible?: boolean
class?: any
ui?: Slideover['slots']
}
export interface SlideoverEmits extends DialogRootEmits {
'after:leave': []
}
export interface SlideoverSlots {
default: (props: { open: boolean }) => any
content: (props?: object) => any
header: (props?: object) => any
title: (props?: object) => any
description: (props?: object) => any
close: (props: { ui: { [K in keyof Required<Slideover['slots']>]: (props?: Record<string, any>) => string } }) => any
body: (props?: object) => any
footer: (props?: object) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<SlideoverProps>(), {
close: true,
portal: true,
overlay: true,
transition: true,
modal: true,
dismissible: true,
side: 'right',
})
const emits = defineEmits<SlideoverEmits>()
const slots = defineSlots<SlideoverSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
const events = {
closeAutoFocus: (e: Event) => e.preventDefault(),
}
if (!props.dismissible) {
return {
pointerDownOutside: (e: Event) => e.preventDefault(),
interactOutside: (e: Event) => e.preventDefault(),
escapeKeyDown: (e: Event) => e.preventDefault(),
...events,
}
}
return events
})
const ui = computed(() => tv(theme)({
transition: props.transition,
side: props.side,
}))
</script>
<!-- eslint-disable vue/custom-event-name-casing -->
<template>
<DialogRoot v-slot="{ open }" v-bind="rootProps">
<DialogTrigger v-if="!!slots.default" as-child :class="props.class">
<slot :open="open" />
</DialogTrigger>
<DialogPortal v-bind="portalProps">
<DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
<DialogContent :data-side="side" :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
<VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
<DialogTitle v-if="title || !!slots.title">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || !!slots.description">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
</VisuallyHidden>
<slot name="content">
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
<slot name="header">
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
</div>
<DialogClose v-if="close || !!slots.close" as-child>
<slot name="close" :ui="ui">
<Button
v-if="close"
:icon="closeIcon"
size="md"
color="neutral"
variant="ghost"
aria-label="close"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })"
/>
</slot>
</DialogClose>
</slot>
</div>
<div :class="ui.body({ class: props.ui?.body })">
<slot name="body" />
</div>
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
<slot name="footer" />
</div>
</slot>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>vue
<script lang="ts">
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { ComponentConfig } from '@/UI/Utils/utils'
import type { DialogContentEmits, DialogContentProps, DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import Button from '@/UI/Components/Button.vue'
import { usePortal } from '@/UI/Composables/usePortal'
import theme from '@/UI/Theme/slideover'
import { reactivePick } from '@vueuse/shared'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, useForwardPropsEmits, VisuallyHidden } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'
type Slideover = ComponentConfig<typeof theme>
export interface SlideoverProps extends DialogRootProps {
title?: string
description?: string
content?: Omit<DialogContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DialogContentEmits>>
overlay?: boolean
transition?: boolean
side?: Slideover['variants']['side']
portal?: boolean | string | HTMLElement
close?: boolean | Partial<ButtonProps>
closeIcon?: string
dismissible?: boolean
class?: any
ui?: Slideover['slots']
}
export interface SlideoverEmits extends DialogRootEmits {
'after:leave': []
}
export interface SlideoverSlots {
default: (props: { open: boolean }) => any
content: (props?: object) => any
header: (props?: object) => any
title: (props?: object) => any
description: (props?: object) => any
close: (props: { ui: { [K in keyof Required<Slideover['slots']>]: (props?: Record<string, any>) => string } }) => any
body: (props?: object) => any
footer: (props?: object) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<SlideoverProps>(), {
close: true,
portal: true,
overlay: true,
transition: true,
modal: true,
dismissible: true,
side: 'right',
})
const emits = defineEmits<SlideoverEmits>()
const slots = defineSlots<SlideoverSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => props.content)
const contentEvents = computed(() => {
const events = {
closeAutoFocus: (e: Event) => e.preventDefault(),
}
if (!props.dismissible) {
return {
pointerDownOutside: (e: Event) => e.preventDefault(),
interactOutside: (e: Event) => e.preventDefault(),
escapeKeyDown: (e: Event) => e.preventDefault(),
...events,
}
}
return events
})
const ui = computed(() => tv(theme)({
transition: props.transition,
side: props.side,
}))
</script>
<!-- eslint-disable vue/custom-event-name-casing -->
<template>
<DialogRoot v-slot="{ open }" v-bind="rootProps">
<DialogTrigger v-if="!!slots.default" as-child :class="props.class">
<slot :open="open" />
</DialogTrigger>
<DialogPortal v-bind="portalProps">
<DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
<DialogContent :data-side="side" :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
<VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
<DialogTitle v-if="title || !!slots.title">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || !!slots.description">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
</VisuallyHidden>
<slot name="content">
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
<slot name="header">
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title">
{{ title }}
</slot>
</DialogTitle>
<DialogDescription v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description">
{{ description }}
</slot>
</DialogDescription>
</div>
<DialogClose v-if="close || !!slots.close" as-child>
<slot name="close" :ui="ui">
<Button
v-if="close"
:icon="closeIcon"
size="md"
color="neutral"
variant="ghost"
aria-label="close"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })"
/>
</slot>
</DialogClose>
</slot>
</div>
<div :class="ui.body({ class: props.ui?.body })">
<slot name="body" />
</div>
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
<slot name="footer" />
</div>
</slot>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>Theme
ts
export default {
slots: {
overlay: '',
content: '',
header: '',
wrapper: '',
body: '',
footer: '',
title: '',
description: '',
close: '',
},
variants: {
side: {
top: {
content: '',
},
right: {
content: '',
},
bottom: {
content: '',
},
left: {
content: '',
},
},
transition: {
true: {
overlay: '',
},
},
},
compoundVariants: [],
}View Nuxt UI theme
ts
export default {
slots: {
overlay: 'fixed inset-0 bg-elevated/75',
content: 'fixed bg-default divide-y divide-default sm:ring ring-default sm:shadow-lg flex flex-col focus:outline-none',
header: 'flex items-center gap-1.5 p-4 sm:px-6 min-h-16',
wrapper: '',
body: 'flex-1 overflow-y-auto p-4 sm:p-6',
footer: 'flex items-center gap-1.5 p-4 sm:px-6',
title: 'text-highlighted font-semibold',
description: 'mt-1 text-muted text-sm',
close: 'absolute top-4 end-4',
},
variants: {
side: {
top: {
content: 'inset-x-0 top-0 max-h-full',
},
right: {
content: 'right-0 inset-y-0 w-full max-w-md',
},
bottom: {
content: 'inset-x-0 bottom-0 max-h-full',
},
left: {
content: 'left-0 inset-y-0 w-full max-w-md',
},
},
transition: {
true: {
overlay: 'data-[state=open]:animate-[fade-in_200ms_ease-out] data-[state=closed]:animate-[fade-out_200ms_ease-in]',
},
},
},
compoundVariants: [
{
transition: true,
side: 'top',
class: {
content: 'data-[state=open]:animate-[slide-in-from-top_200ms_ease-in-out] data-[state=closed]:animate-[slide-out-to-top_200ms_ease-in-out]',
},
} as const,
{
transition: true,
side: 'right',
class: {
content: 'data-[state=open]:animate-[slide-in-from-right_200ms_ease-in-out] data-[state=closed]:animate-[slide-out-to-right_200ms_ease-in-out]',
},
} as const,
{
transition: true,
side: 'bottom',
class: {
content: 'data-[state=open]:animate-[slide-in-from-bottom_200ms_ease-in-out] data-[state=closed]:animate-[slide-out-to-bottom_200ms_ease-in-out]',
},
} as const,
{
transition: true,
side: 'left',
class: {
content: 'data-[state=open]:animate-[slide-in-from-left_200ms_ease-in-out] data-[state=closed]:animate-[slide-out-to-left_200ms_ease-in-out]',
},
} as const,
],
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import Slideover from '@/ui/components/Slideover.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('slideover', () => {
const props = { open: true, portal: false }
it.each<[string, RenderOptions<typeof Slideover>]>([
// Props
['with open', { props }],
['with title', { props: { ...props, title: 'Title' } }],
['with description', { props: { ...props, title: 'Title', description: 'Description' } }],
['with left side', { props: { ...props, side: 'left' as const, title: 'Title', description: 'Description' } }],
['with top side', { props: { ...props, side: 'top' as const, title: 'Title', description: 'Description' } }],
['with bottom side', { props: { ...props, side: 'bottom' as const, title: 'Title', description: 'Description' } }],
['without overlay', { props: { ...props, overlay: false, title: 'Title', description: 'Description' } }],
['without transition', { props: { ...props, transition: false, title: 'Title', description: 'Description' } }],
['without close', { props: { ...props, close: false, title: 'Title', description: 'Description' } }],
['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)' } }],
['with ui', { props: { ...props, ui: { close: 'end-2' } } }],
// Slots
['with default slot', { props, slots: { default: () => 'Default slot' } }],
['with content slot', { props, slots: { content: () => 'Content slot' } }],
['with header slot', { props, slots: { header: () => 'Header slot' } }],
['with title slot', { props, slots: { title: () => 'Title slot' } }],
['with description slot', { props, slots: { description: () => 'Description slot' } }],
['with close slot', { props, slots: { close: () => 'Close slot' } }],
['with body slot', { props, slots: { body: () => 'Body slot' } }],
['with footer slot', { props, slots: { footer: () => 'Footer slot' } }],
] as const)('renders %s correctly', async (name, options) => {
const { html } = render(Slideover, options)
await new Promise(resolve => setTimeout(resolve, 0))
expect(html()).matchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import Slideover from '@/UI/Components/Slideover.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('slideover', () => {
const props = { open: true, portal: false }
it.each<[string, RenderOptions<typeof Slideover>]>([
// Props
['with open', { props }],
['with title', { props: { ...props, title: 'Title' } }],
['with description', { props: { ...props, title: 'Title', description: 'Description' } }],
['with left side', { props: { ...props, side: 'left' as const, title: 'Title', description: 'Description' } }],
['with top side', { props: { ...props, side: 'top' as const, title: 'Title', description: 'Description' } }],
['with bottom side', { props: { ...props, side: 'bottom' as const, title: 'Title', description: 'Description' } }],
['without overlay', { props: { ...props, overlay: false, title: 'Title', description: 'Description' } }],
['without transition', { props: { ...props, transition: false, title: 'Title', description: 'Description' } }],
['without close', { props: { ...props, close: false, title: 'Title', description: 'Description' } }],
['with class', { props: { ...props, class: 'bg-(--ui-bg-elevated)' } }],
['with ui', { props: { ...props, ui: { close: 'end-2' } } }],
// Slots
['with default slot', { props, slots: { default: () => 'Default slot' } }],
['with content slot', { props, slots: { content: () => 'Content slot' } }],
['with header slot', { props, slots: { header: () => 'Header slot' } }],
['with title slot', { props, slots: { title: () => 'Title slot' } }],
['with description slot', { props, slots: { description: () => 'Description slot' } }],
['with close slot', { props, slots: { close: () => 'Close slot' } }],
['with body slot', { props, slots: { body: () => 'Body slot' } }],
['with footer slot', { props, slots: { footer: () => 'Footer slot' } }],
] as const)('renders %s correctly', async (name, options) => {
const { html } = render(Slideover, options)
await new Promise(resolve => setTimeout(resolve, 0))
expect(html()).matchSnapshot()
})
})