Alert
A callout to draw user's attention.
Demo
Basic Alert
This is a basic alert with title and description.
Related Components
This requires the following components 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 { AvatarProps } from '@/ui/components/Avatar.vue'
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { ComponentConfig } from '@/ui/utils/utils'
import Avatar from '@/ui/components/Avatar.vue'
import Button from '@/ui/components/Button.vue'
import Icon from '@/ui/components/Icon.vue'
import { closeIcon } from '@/ui/icons'
import theme from '@/ui/theme/alert'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
type Alert = ComponentConfig<typeof theme>
export interface AlertProps {
as?: any
title?: string
description?: string
icon?: string
avatar?: AvatarProps
color?: Alert['variants']['color']
variant?: Alert['variants']['variant']
orientation?: Alert['variants']['orientation']
actions?: ButtonProps[]
close?: boolean | Partial<ButtonProps>
class?: any
ui?: Alert['slots']
}
export interface AlertEmits {
(e: 'update:open', value: boolean): void
}
export interface AlertSlots {
leading: (props?: object) => any
title: (props?: object) => any
description: (props?: object) => any
actions: (props?: object) => any
close: (props: { ui: { [K in keyof Required<Alert['slots']>]: (props?: Record<string, any>) => string } }) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<AlertProps>(), {
orientation: 'vertical',
})
const emits = defineEmits<AlertEmits>()
const slots = defineSlots<AlertSlots>()
const ui = computed(() => tv(theme)({
color: props.color,
variant: props.variant,
orientation: props.orientation,
title: !!props.title || !!slots.title,
}))
</script>
<template>
<Primitive :as="as" :data-orientation="orientation" :class="ui.root({ class: [props.class, props.ui?.root] })">
<slot name="leading">
<Avatar v-if="avatar" :size="((props.ui?.avatarSize || ui.avatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.avatar({ class: props.ui?.avatar })" />
<Icon v-else-if="icon" :name="icon" :class="ui.icon({ class: props.ui?.icon })" />
</slot>
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<div v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title">
{{ title }}
</slot>
</div>
<div v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description">
{{ description }}
</slot>
</div>
<div v-if="orientation === 'vertical' && (actions?.length || !!slots.actions)" :class="ui.actions({ class: props.ui?.actions })">
<slot name="actions">
<Button v-for="(action, index) in actions" :key="index" size="xs" v-bind="action" />
</slot>
</div>
</div>
<div v-if="(orientation === 'horizontal' && (actions?.length || !!slots.actions)) || close" :class="ui.actions({ class: props.ui?.actions, orientation: 'horizontal' })">
<template v-if="orientation === 'horizontal' && (actions?.length || !!slots.actions)">
<slot name="actions">
<Button v-for="(action, index) in actions" :key="index" size="xs" v-bind="action" />
</slot>
</template>
<slot name="close" :ui="ui">
<Button
v-if="close"
:icon="closeIcon"
size="md"
color="neutral"
variant="link"
aria-label="close"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })"
@click="emits('update:open', false)"
/>
</slot>
</div>
</Primitive>
</template>vue
<script lang="ts">
import type { AvatarProps } from '@/UI/Components/Avatar.vue'
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { ComponentConfig } from '@/UI/Utils/utils'
import Avatar from '@/UI/Components/Avatar.vue'
import Button from '@/UI/Components/Button.vue'
import Icon from '@/UI/Components/Icon.vue'
import { closeIcon } from '@/UI/icons'
import theme from '@/UI/Theme/alert'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
type Alert = ComponentConfig<typeof theme>
export interface AlertProps {
as?: any
title?: string
description?: string
icon?: string
avatar?: AvatarProps
color?: Alert['variants']['color']
variant?: Alert['variants']['variant']
orientation?: Alert['variants']['orientation']
actions?: ButtonProps[]
close?: boolean | Partial<ButtonProps>
class?: any
ui?: Alert['slots']
}
export interface AlertEmits {
(e: 'update:open', value: boolean): void
}
export interface AlertSlots {
leading: (props?: object) => any
title: (props?: object) => any
description: (props?: object) => any
actions: (props?: object) => any
close: (props: { ui: { [K in keyof Required<Alert['slots']>]: (props?: Record<string, any>) => string } }) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<AlertProps>(), {
orientation: 'vertical',
})
const emits = defineEmits<AlertEmits>()
const slots = defineSlots<AlertSlots>()
const ui = computed(() => tv(theme)({
color: props.color,
variant: props.variant,
orientation: props.orientation,
title: !!props.title || !!slots.title,
}))
</script>
<template>
<Primitive :as="as" :data-orientation="orientation" :class="ui.root({ class: [props.class, props.ui?.root] })">
<slot name="leading">
<Avatar v-if="avatar" :size="((props.ui?.avatarSize || ui.avatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.avatar({ class: props.ui?.avatar })" />
<Icon v-else-if="icon" :name="icon" :class="ui.icon({ class: props.ui?.icon })" />
</slot>
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<div v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title">
{{ title }}
</slot>
</div>
<div v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description">
{{ description }}
</slot>
</div>
<div v-if="orientation === 'vertical' && (actions?.length || !!slots.actions)" :class="ui.actions({ class: props.ui?.actions })">
<slot name="actions">
<Button v-for="(action, index) in actions" :key="index" size="xs" v-bind="action" />
</slot>
</div>
</div>
<div v-if="(orientation === 'horizontal' && (actions?.length || !!slots.actions)) || close" :class="ui.actions({ class: props.ui?.actions, orientation: 'horizontal' })">
<template v-if="orientation === 'horizontal' && (actions?.length || !!slots.actions)">
<slot name="actions">
<Button v-for="(action, index) in actions" :key="index" size="xs" v-bind="action" />
</slot>
</template>
<slot name="close" :ui="ui">
<Button
v-if="close"
:icon="closeIcon"
size="md"
color="neutral"
variant="link"
aria-label="close"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })"
@click="emits('update:open', false)"
/>
</slot>
</div>
</Primitive>
</template>Theme
ts
export default {
slots: {
root: '',
wrapper: '',
title: '',
description: '',
icon: '',
avatar: '',
avatarSize: '',
actions: '',
close: '',
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: '',
},
variant: {
solid: '',
outline: '',
soft: '',
subtle: '',
},
orientation: {
horizontal: {
root: '',
actions: '',
},
vertical: {
root: '',
actions: '',
},
},
title: {
true: {
description: '',
},
},
},
compoundVariants: [],
defaultVariants: {
color: 'primary',
variant: 'solid',
} as const,
}View Nuxt UI theme
ts
export default {
slots: {
root: 'relative overflow-hidden w-full rounded-lg p-4 flex gap-2.5',
wrapper: 'min-w-0 flex-1 flex flex-col',
title: 'text-sm font-medium',
description: 'text-sm opacity-90',
icon: 'shrink-0 size-5',
avatar: 'shrink-0',
avatarSize: '2xl',
actions: 'flex flex-wrap gap-1.5 shrink-0',
close: 'p-0',
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: '',
},
variant: {
solid: '',
outline: '',
soft: '',
subtle: '',
},
orientation: {
horizontal: {
root: 'items-center',
actions: 'items-center',
},
vertical: {
root: 'items-start',
actions: 'items-start mt-2.5',
},
},
title: {
true: {
description: 'mt-1',
},
},
},
compoundVariants: [
{
color: 'primary',
variant: 'solid',
class: {
root: 'bg-primary text-inverted',
},
} as const,
{
color: 'primary',
variant: 'outline',
class: {
root: 'text-primary ring ring-inset ring-primary/25',
},
} as const,
{
color: 'primary',
variant: 'soft',
class: {
root: 'bg-primary/10 text-primary',
},
} as const,
{
color: 'primary',
variant: 'subtle',
class: {
root: 'bg-primary/10 text-primary ring ring-inset ring-primary/25',
},
} as const,
{
color: 'secondary',
variant: 'solid',
class: { root: 'bg-secondary text-inverted' },
} as const,
{
color: 'secondary',
variant: 'outline',
class: { root: 'text-secondary ring ring-inset ring-secondary/25' },
} as const,
{
color: 'secondary',
variant: 'soft',
class: { root: 'bg-secondary/10 text-secondary' },
} as const,
{
color: 'secondary',
variant: 'subtle',
class: { root: 'bg-secondary/10 text-secondary ring ring-inset ring-secondary/25' },
} as const,
{
color: 'success',
variant: 'solid',
class: { root: 'bg-success text-inverted' },
} as const,
{
color: 'success',
variant: 'outline',
class: { root: 'text-success ring ring-inset ring-success/25' },
} as const,
{
color: 'success',
variant: 'soft',
class: { root: 'bg-success/10 text-success' },
} as const,
{
color: 'success',
variant: 'subtle',
class: { root: 'bg-success/10 text-success ring ring-inset ring-success/25' },
} as const,
{
color: 'info',
variant: 'solid',
class: { root: 'bg-info text-inverted' },
} as const,
{
color: 'info',
variant: 'outline',
class: { root: 'text-info ring ring-inset ring-info/25' },
} as const,
{
color: 'info',
variant: 'soft',
class: { root: 'bg-info/10 text-info' },
} as const,
{
color: 'info',
variant: 'subtle',
class: { root: 'bg-info/10 text-info ring ring-inset ring-info/25' },
} as const,
{
color: 'warning',
variant: 'solid',
class: { root: 'bg-warning text-inverted' },
} as const,
{
color: 'warning',
variant: 'outline',
class: { root: 'text-warning ring ring-inset ring-warning/25' },
} as const,
{
color: 'warning',
variant: 'soft',
class: { root: 'bg-warning/10 text-warning' },
} as const,
{
color: 'warning',
variant: 'subtle',
class: { root: 'bg-warning/10 text-warning ring ring-inset ring-warning/25' },
} as const,
{
color: 'error',
variant: 'solid',
class: { root: 'bg-error text-inverted' },
} as const,
{
color: 'error',
variant: 'outline',
class: { root: 'text-error ring ring-inset ring-error/25' },
} as const,
{
color: 'error',
variant: 'soft',
class: { root: 'bg-error/10 text-error' },
} as const,
{
color: 'error',
variant: 'subtle',
class: { root: 'bg-error/10 text-error ring ring-inset ring-error/25' },
} as const,
{
color: 'neutral',
variant: 'solid',
class: {
root: 'text-inverted bg-inverted',
},
} as const,
{
color: 'neutral',
variant: 'outline',
class: {
root: 'text-highlighted bg-default ring ring-inset ring-default',
},
} as const,
{
color: 'neutral',
variant: 'soft',
class: {
root: 'text-highlighted bg-elevated/50',
},
} as const,
{
color: 'neutral',
variant: 'subtle',
class: {
root: 'text-highlighted bg-elevated/50 ring ring-inset ring-accented',
},
} as const,
],
defaultVariants: {
color: 'primary',
variant: 'solid',
} as const,
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import Alert from '@/ui/components/Alert.vue'
import theme from '@/ui/theme/alert'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('alert', () => {
const variants = Object.keys(theme.variants.variant) as any
const props = { title: 'Alert' }
it.each<RenderOptions<typeof Alert>[]>([
// Props
['with title', { props }],
['with description', { props: { ...props, description: 'Description' } }],
['with icon', { props: { ...props, icon: 'i-lucide-lightbulb' } }],
['with avatar', { props: { ...props, avatar: { src: 'https://github.com/vue.png' } } }],
['with actions', { props: { ...props, actions: [{ label: 'Action' }] } }],
['with orientation vertical', { props: { ...props, icon: 'i-lucide-lightbulb', description: 'This is a description', actions: [{ label: 'Action' }], orientation: 'vertical' as const } }],
['with orientation horizontal', { props: { ...props, icon: 'i-lucide-lightbulb', description: 'This is a description', actions: [{ label: 'Action' }], orientation: 'horizontal' as const } }],
['with close', { props: { ...props, close: true } }],
...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { ...props, variant } }]),
...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { ...props, variant, color: 'neutral' } }]),
['with as', { props: { ...props, as: 'article' } }],
['with class', { props: { ...props, class: 'w-48' } }],
['with ui', { props: { ...props, ui: { title: 'font-bold' } } }],
// Slots
['with leading slot', { props, slots: { title: () => 'Leading 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' } }],
])('renders %s correctly', (name, options) => {
render(Alert, {
attrs: {
'data-testid': 'alert',
},
...options,
})
expect(screen.getByTestId('alert')).toMatchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import Alert from '@/UI/Components/Alert.vue'
import theme from '@/UI/Theme/alert'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('alert', () => {
const variants = Object.keys(theme.variants.variant) as any
const props = { title: 'Alert' }
it.each<RenderOptions<typeof Alert>[]>([
// Props
['with title', { props }],
['with description', { props: { ...props, description: 'Description' } }],
['with icon', { props: { ...props, icon: 'i-lucide-lightbulb' } }],
['with avatar', { props: { ...props, avatar: { src: 'https://github.com/vue.png' } } }],
['with actions', { props: { ...props, actions: [{ label: 'Action' }] } }],
['with orientation vertical', { props: { ...props, icon: 'i-lucide-lightbulb', description: 'This is a description', actions: [{ label: 'Action' }], orientation: 'vertical' as const } }],
['with orientation horizontal', { props: { ...props, icon: 'i-lucide-lightbulb', description: 'This is a description', actions: [{ label: 'Action' }], orientation: 'horizontal' as const } }],
['with close', { props: { ...props, close: true } }],
...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { ...props, variant } }]),
...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { ...props, variant, color: 'neutral' } }]),
['with as', { props: { ...props, as: 'article' } }],
['with class', { props: { ...props, class: 'w-48' } }],
['with ui', { props: { ...props, ui: { title: 'font-bold' } } }],
// Slots
['with leading slot', { props, slots: { title: () => 'Leading 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' } }],
])('renders %s correctly', (name, options) => {
render(Alert, {
attrs: {
'data-testid': 'alert',
},
...options,
})
expect(screen.getByTestId('alert')).toMatchSnapshot()
})
})