Skip to content

Separator

Separates content horizontally or vertically.

Demo

This requires the following components to be installed:

This requires the following theme to be installed:

Component

Separator.vue
vue
<script lang="ts">
import type { AvatarProps } from '@/ui/components/Avatar.vue'
import type { SeparatorProps as _SeparatorProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Avatar from '@/ui/components/Avatar.vue'
import Icon from '@/ui/components/Icon.vue'
import theme from '@/ui/theme/separator'
import { reactivePick } from '@vueuse/core'
import { Separator, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'

const separator = tv(theme)

type SeparatorVariants = VariantProps<typeof separator>

export interface SeparatorProps extends Pick<_SeparatorProps, 'decorative'> {
  as?: any
  label?: string
  avatar?: AvatarProps
  icon?: string
  color?: SeparatorVariants['color']
  size?: SeparatorVariants['size']
  type?: SeparatorVariants['type']
  orientation?: SeparatorVariants['orientation']
  class?: any
  ui?: Partial<typeof separator.slots>
}

export interface SeparatorSlots {
  default: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<SeparatorProps>(), {
  orientation: 'horizontal',
})
const slots = defineSlots<SeparatorSlots>()

const rootProps = useForwardProps(reactivePick(props, 'as', 'decorative', 'orientation'))

const ui = computed(() => separator({
  color: props.color,
  size: props.size,
  type: props.type,
  orientation: props.orientation,
}))
</script>

<template>
  <Separator v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <div :class="ui.border({ class: props.ui?.border })" />

    <template v-if="label || icon || avatar || !!slots.default">
      <div :class="ui.container({ class: props.ui?.container })">
        <slot>
          <span v-if="label" :class="ui.label({ class: props.ui?.label })">{{ label }}</span>
          <Icon v-else-if="icon" :name="icon" :class="ui.icon({ class: props.ui?.icon })" />
          <Avatar v-else-if="avatar" :size="((props.ui?.avatarSize || ui.avatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.avatar({ class: props.ui?.avatar })" />
        </slot>
      </div>

      <div :class="ui.border({ class: props.ui?.border })" />
    </template>
  </Separator>
</template>
Separator.vue
vue
<script lang="ts">
import type { AvatarProps } from '@/UI/Components/Avatar.vue'
import type { SeparatorProps as _SeparatorProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Avatar from '@/UI/Components/Avatar.vue'
import Icon from '@/UI/Components/Icon.vue'
import theme from '@/UI/Theme/separator'
import { reactivePick } from '@vueuse/core'
import { Separator, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'

const separator = tv(theme)

type SeparatorVariants = VariantProps<typeof separator>

export interface SeparatorProps extends Pick<_SeparatorProps, 'decorative'> {
  as?: any
  label?: string
  avatar?: AvatarProps
  icon?: string
  color?: SeparatorVariants['color']
  size?: SeparatorVariants['size']
  type?: SeparatorVariants['type']
  orientation?: SeparatorVariants['orientation']
  class?: any
  ui?: Partial<typeof separator.slots>
}

export interface SeparatorSlots {
  default: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = withDefaults(defineProps<SeparatorProps>(), {
  orientation: 'horizontal',
})
const slots = defineSlots<SeparatorSlots>()

const rootProps = useForwardProps(reactivePick(props, 'as', 'decorative', 'orientation'))

const ui = computed(() => separator({
  color: props.color,
  size: props.size,
  type: props.type,
  orientation: props.orientation,
}))
</script>

<template>
  <Separator v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <div :class="ui.border({ class: props.ui?.border })" />

    <template v-if="label || icon || avatar || !!slots.default">
      <div :class="ui.container({ class: props.ui?.container })">
        <slot>
          <span v-if="label" :class="ui.label({ class: props.ui?.label })">{{ label }}</span>
          <Icon v-else-if="icon" :name="icon" :class="ui.icon({ class: props.ui?.icon })" />
          <Avatar v-else-if="avatar" :size="((props.ui?.avatarSize || ui.avatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.avatar({ class: props.ui?.avatar })" />
        </slot>
      </div>

      <div :class="ui.border({ class: props.ui?.border })" />
    </template>
  </Separator>
</template>

Theme

separator.ts
ts
export default {
  slots: {
    root: '',
    border: '',
    container: '',
    icon: '',
    avatar: '',
    avatarSize: '',
    label: '',
  },
  variants: {
    color: {
      primary: { border: '' },
      secondary: { border: '' },
      success: { border: '' },
      info: { border: '' },
      warning: { border: '' },
      error: { border: '' },
      neutral: { border: '' },
    },
    orientation: {
      horizontal: {
        root: '',
        border: '',
        container: '',
      },
      vertical: {
        root: '',
        border: '',
        container: '',
      },
    },
    size: {
      xs: '',
      sm: '',
      md: '',
      lg: '',
      xl: '',
    },
    type: {
      solid: {
        border: '',
      },
      dashed: {
        border: '',
      },
      dotted: {
        border: '',
      },
    },
  },
  compoundVariants: [],
  defaultVariants: {
    color: 'neutral',
    size: 'xs',
    type: 'solid',
  } as const,
}
View Nuxt UI theme
separator.ts
ts
export default {
  slots: {
    root: 'flex items-center align-center text-center',
    border: '',
    container: 'font-medium text-default flex',
    icon: 'shrink-0 size-5',
    avatar: 'shrink-0',
    avatarSize: '2xs',
    label: 'text-sm',
  },
  variants: {
    color: {
      primary: { border: 'border-primary' },
      secondary: { border: 'border-secondary' },
      success: { border: 'border-success' },
      info: { border: 'border-info' },
      warning: { border: 'border-warning' },
      error: { border: 'border-error' },
      neutral: { border: 'border-default' },
    },
    orientation: {
      horizontal: {
        root: 'w-full flex-row',
        border: 'w-full',
        container: 'mx-3 whitespace-nowrap',
      },
      vertical: {
        root: 'h-full flex-col',
        border: 'h-full',
        container: 'my-2',
      },
    },
    size: {
      xs: '',
      sm: '',
      md: '',
      lg: '',
      xl: '',
    },
    type: {
      solid: {
        border: 'border-solid',
      },
      dashed: {
        border: 'border-dashed',
      },
      dotted: {
        border: 'border-dotted',
      },
    },
  },
  compoundVariants: [
    {
      orientation: 'horizontal',
      size: 'xs',
      class: { border: 'border-t' },
    } as const,
    {
      orientation: 'horizontal',
      size: 'sm',
      class: { border: 'border-t-[2px]' },
    } as const,
    {
      orientation: 'horizontal',
      size: 'md',
      class: { border: 'border-t-[3px]' },
    } as const,
    {
      orientation: 'horizontal',
      size: 'lg',
      class: { border: 'border-t-[4px]' },
    } as const,
    {
      orientation: 'horizontal',
      size: 'xl',
      class: { border: 'border-t-[5px]' },
    } as const,
    {
      orientation: 'vertical',
      size: 'xs',
      class: { border: 'border-s' },
    } as const,
    {
      orientation: 'vertical',
      size: 'sm',
      class: { border: 'border-s-[2px]' },
    } as const,
    {
      orientation: 'vertical',
      size: 'md',
      class: { border: 'border-s-[3px]' },
    } as const,
    {
      orientation: 'vertical',
      size: 'lg',
      class: { border: 'border-s-[4px]' },
    } as const,
    {
      orientation: 'vertical',
      size: 'xl',
      class: { border: 'border-s-[5px]' },
    } as const,
  ],
  defaultVariants: {
    color: 'neutral',
    size: 'xs',
    type: 'solid',
  } as const,
}

Test

To test this component, you can use the following test file:

Separator.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Separator from '@/ui/components/Separator.vue'
import theme from '@/ui/theme/separator'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('separator', () => {
  const types = Object.keys(theme.variants.type) as any
  const sizes = Object.keys(theme.variants.size) as any

  it.each<RenderOptions<typeof Separator>[]>([
    // Props
    ['with label', { props: { label: '+1' } }],
    ['with icon', { props: { icon: 'i-lucide-image' } }],
    ['with avatar', { props: { avatar: { src: 'https://github.com/vue.png' } } }],
    ['with orientation vertical', { props: { orientation: 'vertical' as const } }],
    ['with decorative', { props: { decorative: true } }],
    ...types.map((type: string) => [`with type ${type}`, { props: { type } }]),
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ['with color primary', { props: { color: 'primary' } }],
    ['with as', { props: { as: 'span' } }],
    ['with class', { props: { class: 'flex-row-reverse' } }],
    ['with ui', { props: { ui: { label: 'text-lg' } } }],
  ])('renders %s correctly', (name, options) => {
    const { html } = render(Separator, options)

    expect(html()).toMatchSnapshot()
  })
})
Separator.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Separator from '@/UI/Components/Separator.vue'
import theme from '@/UI/Theme/separator'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('separator', () => {
  const types = Object.keys(theme.variants.type) as any
  const sizes = Object.keys(theme.variants.size) as any

  it.each<RenderOptions<typeof Separator>[]>([
    // Props
    ['with label', { props: { label: '+1' } }],
    ['with icon', { props: { icon: 'i-lucide-image' } }],
    ['with avatar', { props: { avatar: { src: 'https://github.com/vue.png' } } }],
    ['with orientation vertical', { props: { orientation: 'vertical' as const } }],
    ['with decorative', { props: { decorative: true } }],
    ...types.map((type: string) => [`with type ${type}`, { props: { type } }]),
    ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
    ['with color primary', { props: { color: 'primary' } }],
    ['with as', { props: { as: 'span' } }],
    ['with class', { props: { class: 'flex-row-reverse' } }],
    ['with ui', { props: { ui: { label: 'text-lg' } } }],
  ])('renders %s correctly', (name, options) => {
    const { html } = render(Separator, options)

    expect(html()).toMatchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
2e465 - feat: add separator component (#18) on 12/11/2024