diff --git a/example/cypress/e2e/tabs.cy.ts b/example/cypress/e2e/tabs.cy.ts new file mode 100644 index 00000000..5a0558f1 --- /dev/null +++ b/example/cypress/e2e/tabs.cy.ts @@ -0,0 +1,37 @@ +// https://docs.cypress.io/api/introduction/api.html + +describe('Tabs', () => { + beforeEach(() => { + cy.visit('/tabs'); + }); + + it('check for rendered tabs', () => { + cy.contains('h2[data-cy=tabs]', 'Tabs'); + + cy.get('[data-cy=wrapper-0]').as('wrapper'); + + cy.get('@wrapper') + .find('[data-cy=tabs]') + .find('[role=tablist]') + .as('tablist'); + + cy.get('@tablist').children().should('have.length', 6); + cy.get('@tablist') + .find('button:first-child') + .should('have.class', 'active-tab'); + cy.get('@tablist').find('button:nth-child(2)').should('be.disabled'); + + cy.get('@wrapper').find('[data-cy=tab-items]').as('tabcontent'); + + cy.get('@tabcontent').should('have.text', 'Tab 1 Content'); + + // Click third tab + cy.get('@tablist').find('button:nth-child(3)').click(); + cy.get('@tabcontent').should('have.text', 'Tab 3 Content'); + + // Click last tab should redirect to stepper page + cy.get('@tablist').find('a:last-child').click(); + + cy.url().should('contain', '/steppers'); + }); +}); diff --git a/example/src/App.vue b/example/src/App.vue index 18f70c9f..3c934d8d 100644 --- a/example/src/App.vue +++ b/example/src/App.vue @@ -24,6 +24,7 @@ const navigation = ref([ { to: { name: 'data-tables' }, title: 'Data Tables' }, { to: { name: 'dialogs' }, title: 'Dialogs' }, { to: { name: 'cards' }, title: 'Cards' }, + { to: { name: 'tabs' }, title: 'Tabs' }, ], }, ]); diff --git a/example/src/main.ts b/example/src/main.ts index 7ca592aa..08d869d6 100644 --- a/example/src/main.ts +++ b/example/src/main.ts @@ -7,8 +7,12 @@ import { createPinia } from 'pinia'; import { RiAddFill, RiAlertLine, + RiArrowDownSLine, RiArrowLeftLine, + RiArrowLeftSLine, RiArrowRightLine, + RiArrowRightSLine, + RiArrowUpSLine, RiCheckboxCircleLine, RiCloseFill, RiErrorWarningLine, @@ -38,6 +42,10 @@ app.use(RuiPlugin, { RiCloseFill, RiInformationLine, RiErrorWarningLine, + RiArrowLeftSLine, + RiArrowRightSLine, + RiArrowUpSLine, + RiArrowDownSLine, ], }); diff --git a/example/src/router/index.ts b/example/src/router/index.ts index 9ff6ea09..1c19dd54 100644 --- a/example/src/router/index.ts +++ b/example/src/router/index.ts @@ -86,6 +86,11 @@ const router = createRouter({ name: 'cards', component: () => import('@/views/CardView.vue'), }, + { + path: '/tabs', + name: 'tabs', + component: () => import('@/views/TabView.vue'), + }, ], }); diff --git a/example/src/views/TabView.vue b/example/src/views/TabView.vue new file mode 100644 index 00000000..c49f7d56 --- /dev/null +++ b/example/src/views/TabView.vue @@ -0,0 +1,107 @@ + + + diff --git a/package.json b/package.json index 88347c4b..75922ecd 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,9 @@ "@vueuse/shared": ">10.0.0", "vue": ">=3.3.4" }, + "optionalDependencies": { + "vue-router": ">=3.6.5" + }, "devDependencies": { "@babel/core": "7.22.9", "@babel/types": "7.22.5", @@ -145,6 +148,7 @@ "vitest": "0.34.1", "vue": "3.3.4", "vue-loader": "17.2.2", + "vue-router": "4.2.4", "vue-tsc": "1.8.8" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e204448..6beec750 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + optionalDependencies: + vue-router: + specifier: '>=3.6.5' + version: 4.2.4(vue@3.3.4) devDependencies: '@babel/core': specifier: 7.22.9 @@ -4188,7 +4192,7 @@ packages: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.3.4 - vue-component-type-helpers: 1.8.8 + vue-component-type-helpers: 1.8.10 transitivePeerDependencies: - encoding - supports-color @@ -12745,12 +12749,12 @@ packages: engines: {node: '>=0.10.0'} dev: true - /vue-component-type-helpers@1.8.4: - resolution: {integrity: sha512-6bnLkn8O0JJyiFSIF0EfCogzeqNXpnjJ0vW/SZzNHfe6sPx30lTtTXlE5TFs2qhJlAtDFybStVNpL73cPe3OMQ==} + /vue-component-type-helpers@1.8.10: + resolution: {integrity: sha512-FJtmfw2Gn6eQ8kAVNEhw9nYIzWmVQJjdyQRtJXZ7tgXh/FoZhQnZ2KyxR+NuF9U4iZLBvSspeetIpnP9yxxyMw==} dev: true - /vue-component-type-helpers@1.8.8: - resolution: {integrity: sha512-Ohv9HQY92nSbpReC6WhY0X4YkOszHzwUHaaN/lev5tHQLM1AEw+LrLeB2bIGIyKGDU7ZVrncXcv/oBny4rjbYg==} + /vue-component-type-helpers@1.8.4: + resolution: {integrity: sha512-6bnLkn8O0JJyiFSIF0EfCogzeqNXpnjJ0vW/SZzNHfe6sPx30lTtTXlE5TFs2qhJlAtDFybStVNpL73cPe3OMQ==} dev: true /vue-demi@0.14.5(vue@3.3.4): diff --git a/src/components/buttons/button/Button.vue b/src/components/buttons/button/Button.vue index 62e539f5..7daff4ce 100644 --- a/src/components/buttons/button/Button.vue +++ b/src/components/buttons/button/Button.vue @@ -11,6 +11,7 @@ export interface Props { variant?: 'default' | 'outlined' | 'text' | 'fab'; icon?: boolean; size?: 'sm' | 'lg'; + tag?: 'button' | 'a'; } defineOptions({ @@ -26,6 +27,7 @@ const props = withDefaults(defineProps(), { variant: 'default', icon: false, size: undefined, + tag: 'button', }); const { disabled, elevation, variant, size } = toRefs(props); @@ -63,7 +65,8 @@ const spinnerSize: ComputedRef = computed(() => { diff --git a/src/components/tabs/tab-items/TabItems.spec.ts b/src/components/tabs/tab-items/TabItems.spec.ts new file mode 100644 index 00000000..34979d31 --- /dev/null +++ b/src/components/tabs/tab-items/TabItems.spec.ts @@ -0,0 +1,48 @@ +import { type ComponentMountingOptions, mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import TabItems from '@/components/tabs/tab-items/TabItems.vue'; +import TabItem from '@/components/tabs/tab-item/TabItem.vue'; + +vi.mock('@headlessui/vue', () => ({ + TransitionRoot: { + template: ` +
+ `, + props: { + show: { type: Boolean }, + }, + }, +})); +const createWrapper = (options?: ComponentMountingOptions) => + mount(TabItems, { + ...options, + slots: { + default: [ + h(TabItem, [h('div', 'Tab Content 1')]), + h(TabItem, [h('div', 'Tab Content 2')]), + h(TabItem, [h('div', 'Tab Content 3')]), + h(TabItem, [h('div', 'Tab Content 4')]), + ], + }, + }); + +describe('Tabs/TabItems', () => { + it('renders properly', async () => { + const modelValue = ref(0); + const wrapper = createWrapper({ + props: { + modelValue, + }, + }); + + await nextTick(); + await nextTick(); + expect(wrapper.text()).toBe('Tab Content 1'); + + set(modelValue, 1); + await nextTick(); + await nextTick(); + await nextTick(); + expect(wrapper.text()).toBe('Tab Content 2'); + }); +}); diff --git a/src/components/tabs/tab-items/TabItems.vue b/src/components/tabs/tab-items/TabItems.vue new file mode 100644 index 00000000..9d3e0c19 --- /dev/null +++ b/src/components/tabs/tab-items/TabItems.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/components/tabs/tab/Tab.spec.ts b/src/components/tabs/tab/Tab.spec.ts new file mode 100644 index 00000000..fc006fd5 --- /dev/null +++ b/src/components/tabs/tab/Tab.spec.ts @@ -0,0 +1,107 @@ +import { type ComponentMountingOptions, mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { RouterLinkStub } from '@vue/test-utils'; +import Tab from '@/components/tabs/tab/Tab.vue'; +const createWrapper = (options?: ComponentMountingOptions) => + mount(Tab, { + ...options, + props: { tabValue: 'tab-1', ...options?.props }, + global: { + stubs: { + RouterLink: RouterLinkStub, + }, + }, + }); + +describe('Tabs/Tab', () => { + it('renders properly', () => { + const label = 'Tab 1'; + const wrapper = createWrapper({ + slots: { + prepend: 'prepend', + default: () => label, + }, + }); + const elem = wrapper.find('button'); + expect(elem.classes()).toMatch(/_text_/); + expect(elem.text()).toContain('prepend'); + expect(elem.find('span').text()).toContain(label); + }); + + it('passes disabled props', async () => { + const wrapper = createWrapper(); + expect(wrapper.find('button').attributes('disabled')).toBeUndefined(); + await wrapper.setProps({ disabled: true }); + expect(wrapper.find('button').attributes('disabled')).toBeDefined(); + await wrapper.setProps({ link: true }); + expect(wrapper.find('button').attributes('disabled')).toBeDefined(); + await wrapper.setProps({ disabled: false, link: false }); + expect(wrapper.find('button').attributes('disabled')).toBeUndefined(); + }); + + it('passes color props', async () => { + const wrapper = createWrapper({ + props: { + color: 'primary', + }, + }); + expect(wrapper.find('button').classes()).toMatch(/_grey_/); + + await wrapper.setProps({ active: true }); + expect(wrapper.find('button').classes()).toMatch(/_primary_/); + + await wrapper.setProps({ color: 'secondary' }); + expect(wrapper.find('button').classes()).toMatch(/_secondary_/); + + await wrapper.setProps({ color: 'error' }); + expect(wrapper.find('button').classes()).toMatch(/_error_/); + + await wrapper.setProps({ color: 'success' }); + expect(wrapper.find('button').classes()).toMatch(/_success_/); + }); + + it('passes grow props', async () => { + const wrapper = createWrapper({}); + + expect(wrapper.find('button').classes()).not.toMatch(/--grow_/); + + await wrapper.setProps({ grow: true }); + expect(wrapper.find('button').classes()).toMatch(/--grow_/); + }); + + it('passes align props', async () => { + const wrapper = createWrapper({}); + + expect(wrapper.find('button').classes()).toMatch(/_tab--center_/); + + await wrapper.setProps({ align: 'start' }); + expect(wrapper.find('button').classes()).toMatch(/_tab--start_/); + + await wrapper.setProps({ align: 'end' }); + expect(wrapper.find('button').classes()).toMatch(/_tab--end_/); + }); + + it('tab as link', async () => { + const wrapper = createWrapper({ + props: { + link: true, + to: '/tabs', + exact: true, + exactPath: true, + }, + }); + + let elem = wrapper.find('a'); + expect(elem.classes()).toMatch(/_tab_/); + expect(elem.attributes().target).toMatch('_self'); + expect(elem.attributes().href).toBeUndefined(); + + await wrapper.setProps({ + target: '_blank', + }); + + elem = wrapper.find('a'); + expect(elem.attributes().target).toMatch('_blank'); + expect(elem.attributes().href).toBeDefined(); + }); +}); diff --git a/src/components/tabs/tab/Tab.vue b/src/components/tabs/tab/Tab.vue new file mode 100644 index 00000000..a5fb1f30 --- /dev/null +++ b/src/components/tabs/tab/Tab.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/src/components/tabs/tabs/Tabs.spec.ts b/src/components/tabs/tabs/Tabs.spec.ts new file mode 100644 index 00000000..fd0a3efd --- /dev/null +++ b/src/components/tabs/tabs/Tabs.spec.ts @@ -0,0 +1,133 @@ +import { + type ComponentMountingOptions, + RouterLinkStub, + mount, +} from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import Tabs from '@/components/tabs/tabs/Tabs.vue'; +import Tab from '@/components/tabs/tab/Tab.vue'; + +const createWrapper = (options?: ComponentMountingOptions) => + mount(Tabs, { + ...options, + global: { + stubs: { + RouterLink: RouterLinkStub, + }, + }, + slots: { + default: [ + h(Tab, [h('div', 'Tab 1')]), + h(Tab, [h('div', 'Tab 2')]), + h(Tab, [h('div', 'Tab 3')]), + h(Tab, [h('div', 'Tab 4')]), + ], + }, + }); + +describe('Tabs/Tabs', () => { + it('renders properly', async () => { + const modelValue = ref(); + const wrapper = createWrapper({ + props: { + modelValue, + 'onUpdate:modelValue': (e: any) => set(modelValue, e), + }, + }); + + await nextTick(); + + const buttons = wrapper.findAll('div[class*=_tabs-wrapper] > button'); + + expect(buttons).toHaveLength(4); + expect(buttons[0].classes()).toMatch(/active-tab/); + }); + + it('pass vertical props', async () => { + const wrapper = createWrapper({}); + + expect(wrapper.classes()).not.toMatch(/_tabs--vertical_/); + + await wrapper.setProps({ + vertical: true, + }); + expect(wrapper.classes()).toMatch(/_tabs--vertical_/); + + expect( + wrapper.find('div[class*=_tabs-wrapper] > button').classes(), + ).toMatch(/_tab--vertical_/); + }); + + it('pass grow props', async () => { + const wrapper = createWrapper({}); + + expect(wrapper.find('div[class*=tabs-bar]').classes()).not.toMatch( + /_tabs-bar--grow/, + ); + + await wrapper.setProps({ + grow: true, + }); + expect(wrapper.find('div[class*=tabs-bar]').classes()).toMatch( + /_tabs-bar--grow/, + ); + + expect( + wrapper.find('div[class*=_tabs-wrapper] > button').classes(), + ).toMatch(/_tab--grow/); + }); + + it('pass disabled props', async () => { + const wrapper = createWrapper({}); + + expect(wrapper.find('button').attributes('disabled')).toBeUndefined(); + + await wrapper.setProps({ + disabled: true, + }); + + expect(wrapper.find('button').attributes('disabled')).toBeDefined(); + }); + + it('pass align props', async () => { + const wrapper = createWrapper({}); + + expect(wrapper.find('button').classes()).toMatch(/_tab--center_/); + + await wrapper.setProps({ align: 'start' }); + expect(wrapper.find('button').classes()).toMatch(/_tab--start_/); + + await wrapper.setProps({ align: 'end' }); + expect(wrapper.find('button').classes()).toMatch(/_tab--end_/); + }); + + it('click tab change the modelValue', async () => { + const modelValue = ref(); + const wrapper = createWrapper({ + props: { + modelValue, + 'onUpdate:modelValue': (e: any) => set(modelValue, e), + }, + }); + + await nextTick(); + let buttons = wrapper.findAll('div[class*=_tabs-wrapper] > button'); + + expect(buttons).toHaveLength(4); + expect(buttons[0].classes()).toMatch(/active-tab/); + + await buttons[1].trigger('click'); + await nextTick(); + expect(get(modelValue)).toBe(1); + + buttons = wrapper.findAll('div[class*=_tabs-wrapper] > button'); + await buttons[2].trigger('click'); + await nextTick(); + expect(get(modelValue)).toBe(2); + + buttons = wrapper.findAll('div[class*=_tabs-wrapper] > button'); + await buttons[3].trigger('click'); + await nextTick(); + expect(get(modelValue)).toBe(3); + }); +}); diff --git a/src/components/tabs/tabs/Tabs.stories.ts b/src/components/tabs/tabs/Tabs.stories.ts new file mode 100644 index 00000000..ce2a6ff2 --- /dev/null +++ b/src/components/tabs/tabs/Tabs.stories.ts @@ -0,0 +1,131 @@ +import { type Meta, type StoryFn, type StoryObj } from '@storybook/vue3'; +import { contextColors } from '@/consts/colors'; +import Icon from '@/components/icons/Icon.vue'; +import Card from '@/components/cards/Card.vue'; +import Tab from '@/components/tabs/tab/Tab.vue'; +import TabItems from '@/components/tabs/tab-items/TabItems.vue'; +import TabItem from '@/components/tabs/tab-item/TabItem.vue'; +import Tabs, { type Props } from './Tabs.vue'; + +const render: StoryFn = (args) => ({ + components: { Tabs, Tab, TabItems, TabItem, Card, Icon }, + setup() { + const modelValue = computed({ + get() { + return args.modelValue; + }, + set(val) { + args.modelValue = val; + }, + }); + + return { args, modelValue }; + }, + template: ` +
+ + + + Tab 1 + + Tab 2 + Tab 3 + Tab 4 + Tab 5 + Tab 6 + Tab 7 + Tab 8 + + Tab 9 + + + + + Tab 1 Content + Tab 2 Content + Tab 3 Content + Tab 4 Content + Tab 5 Content + Tab 6 Content + Tab 7 Content + + + Tab 8 Long Long Long Long Long Long Long Long Long Long Long Long + Long Long Long Long Long Long Long Long Long Long Long Long Long Long Long Long Long Long Long Long Long Content + + + Tab 9 Content + +
+ `, +}); + +const meta: Meta = { + title: 'Components/Tabs', + component: Tabs, + tags: ['autodocs'], + render, + argTypes: { + modelValue: { control: 'text' }, + align: { control: 'select', options: ['start', 'center', 'end'] }, + grow: { control: 'boolean', table: { category: 'State' } }, + vertical: { control: 'boolean', table: { category: 'State' } }, + disabled: { control: 'boolean', table: { category: 'State' } }, + color: { + control: 'select', + options: contextColors, + table: { category: 'State' }, + }, + class: { control: 'string' }, + }, +}; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Primary: Story = { + args: { + color: 'primary', + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; + +export const Grow: Story = { + args: { + grow: true, + }, +}; + +export const Vertical: Story = { + args: { + class: 'w-[200px]', + vertical: true, + }, +}; + +export const DefaultWithArrow: Story = { + args: { + class: 'w-[500px]', + }, +}; + +export const VerticalWithArrow: Story = { + args: { + vertical: true, + className: 'w-[200px] h-[300px]', + }, +}; + +export default meta; diff --git a/src/components/tabs/tabs/Tabs.vue b/src/components/tabs/tabs/Tabs.vue new file mode 100644 index 00000000..720ee396 --- /dev/null +++ b/src/components/tabs/tabs/Tabs.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/vite.config.ts b/vite.config.ts index 6f02bfef..1c180f42 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,7 +49,13 @@ export default defineConfig({ formats: ['es'], }, rollupOptions: { - external: ['vue', 'tailwindcss/plugin', '@vueuse/core', '@vueuse/shared'], + external: [ + 'vue', + 'vue-router', + 'tailwindcss/plugin', + '@vueuse/core', + '@vueuse/shared', + ], output: { globals: { vue: 'vue',