mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2026-03-01 12:44:37 +00:00
655 lines
19 KiB
TypeScript
655 lines
19 KiB
TypeScript
import { mount } from '@vue/test-utils'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { defineComponent, h } from 'vue-demi'
|
|
|
|
import OsButton from './OsButton.vue'
|
|
|
|
describe('osButton', () => {
|
|
it('renders slot content', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: { default: 'Click me' },
|
|
})
|
|
|
|
expect(wrapper.text()).toBe('Click me')
|
|
})
|
|
|
|
describe('variant prop', () => {
|
|
it('applies default variant classes by default', () => {
|
|
const wrapper = mount(OsButton)
|
|
|
|
// Default variant with filled appearance
|
|
expect(wrapper.classes()).toContain('bg-[var(--color-default)]')
|
|
})
|
|
|
|
it('applies primary variant classes', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { variant: 'primary' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
|
|
})
|
|
|
|
it('applies danger variant classes', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { variant: 'danger' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('bg-[var(--color-danger)]')
|
|
})
|
|
})
|
|
|
|
describe('appearance prop', () => {
|
|
it('applies filled appearance by default', () => {
|
|
const wrapper = mount(OsButton)
|
|
|
|
expect(wrapper.classes()).toContain('shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)]')
|
|
})
|
|
|
|
it('applies outline appearance classes', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { appearance: 'outline', variant: 'primary' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('bg-transparent')
|
|
expect(wrapper.classes()).toContain('border-[var(--color-primary)]')
|
|
expect(wrapper.classes()).toContain('text-[var(--color-primary)]')
|
|
})
|
|
|
|
it('applies ghost appearance classes', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { appearance: 'ghost', variant: 'primary' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('bg-transparent')
|
|
expect(wrapper.classes()).toContain('text-[var(--color-primary)]')
|
|
expect(wrapper.classes()).not.toContain('border-[var(--color-primary)]')
|
|
})
|
|
})
|
|
|
|
describe('size prop', () => {
|
|
it('applies md size by default', () => {
|
|
const wrapper = mount(OsButton)
|
|
|
|
expect(wrapper.classes()).toContain('h-[36px]')
|
|
})
|
|
|
|
it('applies sm size classes', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { size: 'sm' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('h-[26px]')
|
|
expect(wrapper.classes()).toContain('text-[12px]')
|
|
})
|
|
|
|
it('applies lg size classes', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { size: 'lg' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('h-12')
|
|
})
|
|
|
|
it('has min-width matching height for each size', () => {
|
|
const sizes = {
|
|
sm: 'min-w-[26px]',
|
|
md: 'min-w-[36px]',
|
|
lg: 'min-w-12',
|
|
xl: 'min-w-14',
|
|
} as const
|
|
for (const [size, expected] of Object.entries(sizes)) {
|
|
const wrapper = mount(OsButton, { props: { size: size as keyof typeof sizes } })
|
|
|
|
expect(wrapper.classes()).toContain(expected)
|
|
}
|
|
})
|
|
})
|
|
|
|
it('applies fullWidth class', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { fullWidth: true },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('w-full')
|
|
})
|
|
|
|
it('merges custom classes', () => {
|
|
const wrapper = mount(OsButton, {
|
|
attrs: { class: 'my-custom-class' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('my-custom-class')
|
|
})
|
|
|
|
it('sets disabled attribute', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { disabled: true },
|
|
})
|
|
|
|
expect(wrapper.attributes('disabled')).toBeDefined()
|
|
})
|
|
|
|
it('defaults to type="button"', () => {
|
|
const wrapper = mount(OsButton)
|
|
|
|
expect(wrapper.attributes('type')).toBe('button')
|
|
})
|
|
|
|
it('sets button type', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { type: 'submit' },
|
|
})
|
|
|
|
expect(wrapper.attributes('type')).toBe('submit')
|
|
})
|
|
|
|
it('sets data-variant attribute', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { variant: 'danger' },
|
|
})
|
|
|
|
expect(wrapper.attributes('data-variant')).toBe('danger')
|
|
})
|
|
|
|
it('sets data-appearance attribute', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { appearance: 'outline' },
|
|
})
|
|
|
|
expect(wrapper.attributes('data-appearance')).toBe('outline')
|
|
})
|
|
|
|
it('emits click event', async () => {
|
|
const wrapper = mount(OsButton)
|
|
await wrapper.trigger('click')
|
|
|
|
expect(wrapper.emitted('click')).toHaveLength(1)
|
|
})
|
|
|
|
describe('focus styles', () => {
|
|
it('default variant has dashed outline focus style using currentColor', () => {
|
|
const wrapper = mount(OsButton)
|
|
|
|
expect(wrapper.classes()).toContain('focus:outline-dashed')
|
|
expect(wrapper.classes()).toContain('focus:outline-current')
|
|
expect(wrapper.classes()).toContain('focus:outline-1')
|
|
})
|
|
|
|
it('colored variants have dashed outline focus style', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { variant: 'primary' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('focus:outline-dashed')
|
|
expect(wrapper.classes()).toContain('focus:outline-1')
|
|
})
|
|
})
|
|
|
|
describe('icon slot', () => {
|
|
it('renders icon slot content in .os-button__icon wrapper', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: { icon: '<svg data-testid="icon"></svg>' },
|
|
})
|
|
const iconWrapper = wrapper.find('.os-button__icon')
|
|
|
|
expect(iconWrapper.exists()).toBe(true)
|
|
expect(iconWrapper.find('[data-testid="icon"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('renders both icon and text', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: {
|
|
icon: '<svg data-testid="icon"></svg>',
|
|
default: 'Save',
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.os-button__icon').exists()).toBe(true)
|
|
expect(wrapper.text()).toContain('Save')
|
|
})
|
|
|
|
it('adds gap-2 class when icon and text are present', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: {
|
|
icon: '<svg></svg>',
|
|
default: 'Save',
|
|
},
|
|
})
|
|
const contentSpan = wrapper.find('button > span')
|
|
|
|
expect(contentSpan.classes()).toContain('gap-2')
|
|
})
|
|
|
|
it('adds gap-1 class for small sizes with icon and text', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { size: 'sm' },
|
|
slots: {
|
|
icon: '<svg></svg>',
|
|
default: 'Save',
|
|
},
|
|
})
|
|
const contentSpan = wrapper.find('button > span')
|
|
|
|
expect(contentSpan.classes()).toContain('gap-1')
|
|
expect(contentSpan.classes()).not.toContain('gap-2')
|
|
})
|
|
|
|
it('does not add gap-2 for icon-only button', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: { icon: '<svg></svg>' },
|
|
})
|
|
const contentSpan = wrapper.find('button > span')
|
|
|
|
expect(contentSpan.classes()).not.toContain('gap-2')
|
|
})
|
|
|
|
it('treats whitespace-only text as icon-only', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: {
|
|
icon: '<svg></svg>',
|
|
default: ' ',
|
|
},
|
|
})
|
|
const contentSpan = wrapper.find('button > span')
|
|
|
|
expect(contentSpan.classes()).not.toContain('gap-2')
|
|
expect(wrapper.find('.os-button__icon').classes()).toContain('-mr-1')
|
|
})
|
|
|
|
it('does not add gap-2 without icon', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: { default: 'Click me' },
|
|
})
|
|
const contentSpan = wrapper.find('button > span')
|
|
|
|
expect(contentSpan.classes()).not.toContain('gap-2')
|
|
})
|
|
|
|
it('renders without icon slot (backward compat)', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: { default: 'Click me' },
|
|
})
|
|
|
|
expect(wrapper.find('.os-button__icon').exists()).toBe(false)
|
|
expect(wrapper.text()).toBe('Click me')
|
|
})
|
|
|
|
it('renders icon-only button without text', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: { icon: '<svg></svg>' },
|
|
})
|
|
|
|
expect(wrapper.find('.os-button__icon').exists()).toBe(true)
|
|
expect(wrapper.text()).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('circle prop', () => {
|
|
it('renders as round button with rounded-full and p-0', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: true },
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Add' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('rounded-full')
|
|
expect(wrapper.classes()).toContain('p-0')
|
|
})
|
|
|
|
it('applies w-[36px] width for md size (default)', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: true },
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Add' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('w-[36px]')
|
|
})
|
|
|
|
it('applies w-[26px] width for sm size', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: true, size: 'sm' },
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Add' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('w-[26px]')
|
|
})
|
|
|
|
it('applies w-12 width for lg size', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: true, size: 'lg' },
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Add' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('w-12')
|
|
})
|
|
|
|
it('applies w-14 width for xl size', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: true, size: 'xl' },
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Add' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('w-14')
|
|
})
|
|
|
|
it('is combinable with primary variant', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: true, variant: 'primary' },
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Add' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('rounded-full')
|
|
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
|
|
})
|
|
|
|
it('is combinable with ghost appearance', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: true, appearance: 'ghost', variant: 'primary' },
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Add' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('rounded-full')
|
|
expect(wrapper.classes()).toContain('bg-transparent')
|
|
})
|
|
|
|
it('icon has no negative margin in circle mode', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: true },
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Add' },
|
|
})
|
|
const iconWrapper = wrapper.find('.os-button__icon')
|
|
|
|
expect(iconWrapper.classes()).not.toContain('-ml-1')
|
|
expect(iconWrapper.classes()).not.toContain('-mr-1')
|
|
})
|
|
|
|
it('uses gap-1 for circle with icon and text', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: true },
|
|
slots: {
|
|
icon: '<svg></svg>',
|
|
default: 'Add',
|
|
},
|
|
})
|
|
const contentSpan = wrapper.find('button > span')
|
|
|
|
expect(contentSpan.classes()).toContain('gap-1')
|
|
expect(contentSpan.classes()).not.toContain('gap-2')
|
|
})
|
|
|
|
it('does not apply circle classes when circle is false', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { circle: false },
|
|
slots: { default: 'Click me' },
|
|
})
|
|
|
|
expect(wrapper.classes()).not.toContain('rounded-full')
|
|
expect(wrapper.classes()).not.toContain('p-0')
|
|
})
|
|
})
|
|
|
|
describe('loading prop', () => {
|
|
it('renders spinner SVG when loading=true', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: { default: 'Save' },
|
|
})
|
|
|
|
expect(wrapper.find('.os-button__spinner').exists()).toBe(true)
|
|
expect(wrapper.find('svg').exists()).toBe(true)
|
|
})
|
|
|
|
it('disables button when loading=true', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: { default: 'Save' },
|
|
})
|
|
|
|
expect(wrapper.attributes('disabled')).toBeDefined()
|
|
})
|
|
|
|
it('sets aria-busy="true" when loading', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: { default: 'Save' },
|
|
})
|
|
|
|
expect(wrapper.attributes('aria-busy')).toBe('true')
|
|
})
|
|
|
|
it('keeps content visible when loading', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: { default: 'Save' },
|
|
})
|
|
const contentSpan = wrapper.find('span')
|
|
|
|
expect(contentSpan.classes()).not.toContain('opacity-0')
|
|
expect(wrapper.text()).toContain('Save')
|
|
})
|
|
|
|
it('does not render spinner when loading=false (default)', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: { default: 'Save' },
|
|
})
|
|
|
|
expect(wrapper.find('.os-button__spinner').exists()).toBe(false)
|
|
})
|
|
|
|
it('does not set aria-busy when not loading', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: { default: 'Save' },
|
|
})
|
|
|
|
expect(wrapper.attributes('aria-busy')).toBeUndefined()
|
|
})
|
|
|
|
it('loading + disabled: button remains disabled', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true, disabled: true },
|
|
slots: { default: 'Save' },
|
|
})
|
|
|
|
expect(wrapper.attributes('disabled')).toBeDefined()
|
|
})
|
|
|
|
it('does not emit click when loading', async () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: { default: 'Save' },
|
|
})
|
|
await wrapper.trigger('click')
|
|
|
|
expect(wrapper.emitted('click')).toBeUndefined()
|
|
})
|
|
|
|
it('renders spinner inside icon wrapper when icon is present', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: {
|
|
icon: '<svg data-testid="icon"></svg>',
|
|
default: 'Save',
|
|
},
|
|
})
|
|
const iconWrapper = wrapper.find('.os-button__icon')
|
|
|
|
expect(iconWrapper.exists()).toBe(true)
|
|
expect(iconWrapper.find('.os-button__spinner').exists()).toBe(true)
|
|
})
|
|
|
|
it('keeps icon visible when loading with icon', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: {
|
|
icon: '<svg data-testid="icon"></svg>',
|
|
default: 'Save',
|
|
},
|
|
})
|
|
const iconWrapper = wrapper.find('.os-button__icon')
|
|
|
|
expect(iconWrapper.classes()).not.toContain('[&>*]:invisible')
|
|
})
|
|
|
|
it('renders spinner as direct button child when no icon', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: { default: 'Save' },
|
|
})
|
|
// Spinner is a direct child of button, not inside content wrapper
|
|
const spinner = wrapper.find('button > .os-button__spinner')
|
|
|
|
expect(spinner.exists()).toBe(true)
|
|
})
|
|
|
|
it('does not render button-level spinner when icon is present', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: {
|
|
icon: '<svg></svg>',
|
|
default: 'Save',
|
|
},
|
|
})
|
|
// No spinner as direct child of button — it's inside the icon wrapper
|
|
const buttonSpinner = wrapper.find('button > .os-button__spinner')
|
|
|
|
expect(buttonSpinner.exists()).toBe(false)
|
|
})
|
|
|
|
it('keeps icon visible and shows spinner for icon-only loading', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true },
|
|
slots: { icon: '<svg data-testid="icon"></svg>' },
|
|
})
|
|
const iconWrapper = wrapper.find('.os-button__icon')
|
|
|
|
expect(iconWrapper.exists()).toBe(true)
|
|
expect(iconWrapper.find('[data-testid="icon"]').exists()).toBe(true)
|
|
expect(iconWrapper.find('.os-button__spinner').exists()).toBe(true)
|
|
expect(iconWrapper.classes()).not.toContain('[&>*]:invisible')
|
|
})
|
|
|
|
it('works with circle prop', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { loading: true, circle: true },
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Add' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('rounded-full')
|
|
expect(wrapper.find('.os-button__spinner').exists()).toBe(true)
|
|
expect(wrapper.attributes('disabled')).toBeDefined()
|
|
expect(wrapper.attributes('aria-busy')).toBe('true')
|
|
})
|
|
})
|
|
|
|
describe('as prop', () => {
|
|
it('renders as <button> by default', () => {
|
|
const wrapper = mount(OsButton)
|
|
|
|
expect((wrapper.element as HTMLElement).tagName).toBe('BUTTON')
|
|
expect(wrapper.attributes('type')).toBe('button')
|
|
})
|
|
|
|
it('renders as <a> when as="a"', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { as: 'a' },
|
|
attrs: { href: '/test' },
|
|
slots: { default: 'Link' },
|
|
})
|
|
|
|
expect((wrapper.element as HTMLElement).tagName).toBe('A')
|
|
expect(wrapper.attributes('href')).toBe('/test')
|
|
expect(wrapper.attributes('type')).toBeUndefined()
|
|
})
|
|
|
|
it('renders a component passed as as', () => {
|
|
const FakeLink = defineComponent({
|
|
props: { to: { type: String, default: undefined } },
|
|
setup(props, { slots }) {
|
|
return () => h('a', { href: props.to }, slots.default?.())
|
|
},
|
|
})
|
|
const wrapper = mount(OsButton, {
|
|
props: { as: FakeLink },
|
|
attrs: { to: '/groups' },
|
|
slots: { default: 'Groups' },
|
|
})
|
|
|
|
expect((wrapper.element as HTMLElement).tagName).toBe('A')
|
|
expect(wrapper.text()).toBe('Groups')
|
|
})
|
|
|
|
it('ignores disabled prop on non-button tags', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { as: 'a', disabled: true },
|
|
attrs: { href: '/test' },
|
|
})
|
|
|
|
expect(wrapper.attributes('disabled')).toBeUndefined()
|
|
expect(wrapper.attributes('aria-disabled')).toBeUndefined()
|
|
expect(wrapper.attributes('tabindex')).toBeUndefined()
|
|
})
|
|
|
|
it('applies variant classes regardless of as', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { as: 'a', variant: 'primary', appearance: 'filled' },
|
|
})
|
|
|
|
expect(wrapper.classes()).toContain('os-button')
|
|
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
|
|
})
|
|
})
|
|
|
|
describe('keyboard accessibility', () => {
|
|
it('renders as native button element for keyboard support', () => {
|
|
const wrapper = mount(OsButton)
|
|
|
|
// Native button elements have built-in Enter/Space key support
|
|
expect((wrapper.element as HTMLElement).tagName).toBe('BUTTON')
|
|
})
|
|
|
|
it('is focusable by default', () => {
|
|
const wrapper = mount(OsButton)
|
|
|
|
// No tabindex=-1 means button is in natural tab order
|
|
expect(wrapper.attributes('tabindex')).toBeUndefined()
|
|
})
|
|
|
|
it('remains focusable when disabled via aria', () => {
|
|
const wrapper = mount(OsButton, {
|
|
props: { disabled: true },
|
|
})
|
|
|
|
// Disabled buttons have disabled attribute which browsers handle correctly
|
|
expect(wrapper.attributes('disabled')).toBeDefined()
|
|
})
|
|
|
|
it('icon-only button is focusable with aria-label', () => {
|
|
const wrapper = mount(OsButton, {
|
|
slots: { icon: '<svg></svg>' },
|
|
attrs: { 'aria-label': 'Close' },
|
|
})
|
|
|
|
expect(wrapper.attributes('aria-label')).toBe('Close')
|
|
expect(wrapper.attributes('tabindex')).toBeUndefined()
|
|
})
|
|
|
|
it('can receive focus programmatically', () => {
|
|
const wrapper = mount(OsButton, { attachTo: document.body })
|
|
const button = wrapper.element as HTMLButtonElement
|
|
button.focus()
|
|
|
|
expect(document.activeElement).toBe(button)
|
|
|
|
wrapper.unmount()
|
|
})
|
|
})
|
|
})
|