This is bold text and italic text inside the alert's default slot.
+It overrides the 'message' prop.
+Body
' } }); + expect(wrapper.find('.card-header').exists()).toBe(false); + }); + + it('renders default slot content in .card-body', () => { + const bodyContent = 'Main card content here.
'; + const wrapper = mount(VCard, { slots: { default: bodyContent } }); + const body = wrapper.find('.card-body'); + expect(body.exists()).toBe(true); + expect(body.html()).toContain(bodyContent); + }); + + it('renders footer slot content in .card-footer', () => { + const footerContent = 'Card Footer Text'; + const wrapper = mount(VCard, { slots: { footer: footerContent } }); + const footer = wrapper.find('.card-footer'); + expect(footer.exists()).toBe(true); + expect(footer.html()).toContain(footerContent); + }); + + it('does not render .card-footer if no footer slot', () => { + const wrapper = mount(VCard, { slots: { default: 'Body
' } }); + expect(wrapper.find('.card-footer').exists()).toBe(false); + }); + + it('applies .card class by default', () => { + const wrapper = mount(VCard); + expect(wrapper.classes()).toContain('card'); + expect(wrapper.classes()).not.toContain('empty-state-card'); + }); + }); + + // Empty state variant tests + describe('Empty State Variant', () => { + const emptyStateProps = { + variant: 'empty-state' as const, + emptyIcon: 'alert', + emptyTitle: 'Nothing to Show', + emptyMessage: 'There is no data available at this moment.', + }; + + it('applies .card and .empty-state-card classes', () => { + const wrapper = mount(VCard, { props: emptyStateProps }); + expect(wrapper.classes()).toContain('card'); + expect(wrapper.classes()).toContain('empty-state-card'); + }); + + it('renders empty state icon, title, and message', () => { + const wrapper = mount(VCard, { + props: emptyStateProps, + global: { components: { VIcon } } // Ensure VIcon is available + }); + const icon = wrapper.findComponent(VIcon); // Or find by class if not using findComponent + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe(emptyStateProps.emptyIcon); + + expect(wrapper.find('.empty-state-title').text()).toBe(emptyStateProps.emptyTitle); + expect(wrapper.find('.empty-state-message').text()).toBe(emptyStateProps.emptyMessage); + }); + + it('does not render icon, title, message if props not provided', () => { + const wrapper = mount(VCard, { + props: { variant: 'empty-state' as const }, + global: { components: { VIcon } } + }); + expect(wrapper.findComponent(VIcon).exists()).toBe(false); // Or check for .empty-state-icon + expect(wrapper.find('.empty-state-title').exists()).toBe(false); + expect(wrapper.find('.empty-state-message').exists()).toBe(false); + }); + + it('renders empty-actions slot content', () => { + const actionsContent = ''; + const wrapper = mount(VCard, { + props: emptyStateProps, + slots: { 'empty-actions': actionsContent }, + }); + const actionsContainer = wrapper.find('.empty-state-actions'); + expect(actionsContainer.exists()).toBe(true); + expect(actionsContainer.html()).toContain(actionsContent); + }); + + it('does not render .empty-state-actions if slot is not provided', () => { + const wrapper = mount(VCard, { props: emptyStateProps }); + expect(wrapper.find('.empty-state-actions').exists()).toBe(false); + }); + + it('does not render standard header, body (main slot), or footer in empty state', () => { + const wrapper = mount(VCard, { + props: { ...emptyStateProps, headerTitle: 'Should not show' }, + slots: { + default: 'Standard body
', + footer: 'Standard footer', + }, + }); + expect(wrapper.find('.card-header').exists()).toBe(false); + // The .card-body is used by empty-state-content, so check for specific standard content + expect(wrapper.text()).not.toContain('Standard body'); + expect(wrapper.find('.card-footer').exists()).toBe(false); + }); + }); +}); diff --git a/fe/src/components/valerie/VCard.stories.ts b/fe/src/components/valerie/VCard.stories.ts new file mode 100644 index 0000000..e7378f6 --- /dev/null +++ b/fe/src/components/valerie/VCard.stories.ts @@ -0,0 +1,164 @@ +import VCard from './VCard.vue'; +import VIcon from './VIcon.vue'; // For empty state icon +import VButton from './VButton.vue'; // For empty state actions slot +import type { Meta, StoryObj } from '@storybook/vue3'; + +const meta: MetaThis is the main body content of the card. It can contain any HTML or Vue components.
+Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ + +Card body content goes here.
+ +Simple footer text.
+ +This card only has body content. No header or footer will be rendered.
+It's useful for simple information display.
+This card has a header (via prop) and body content, but no footer.
+Modal body content.
'; + const wrapper = mount(VModal, { + props: { modelValue: true }, + slots: { default: bodyContent }, + attachTo: document.body + }); + await nextTick(); + const body = wrapper.find('.modal-body'); + expect(body.html()).toContain(bodyContent); + }); + + it('renders footer slot content', async () => { + const footerContent = ''; + const wrapper = mount(VModal, { + props: { modelValue: true }, + slots: { footer: footerContent }, + attachTo: document.body + }); + await nextTick(); + const footer = wrapper.find('.modal-footer'); + expect(footer.exists()).toBe(true); + expect(footer.html()).toContain(footerContent); + }); + + it('does not render footer if no slot content', async () => { + const wrapper = mount(VModal, { + props: { modelValue: true }, + attachTo: document.body + }); + await nextTick(); + expect(wrapper.find('.modal-footer').exists()).toBe(false); + }); + + + it('applies correct size class', async () => { + const wrapperSm = mount(VModal, { props: { modelValue: true, size: 'sm' }, attachTo: document.body }); + await nextTick(); + expect(wrapperSm.find('.modal-container').classes()).toContain('modal-container-sm'); + + const wrapperLg = mount(VModal, { props: { modelValue: true, size: 'lg' }, attachTo: document.body }); + await nextTick(); + expect(wrapperLg.find('.modal-container').classes()).toContain('modal-container-lg'); + + document.querySelectorAll('.modal-backdrop').forEach(el => el.remove()); // Manual cleanup for multiple modals + }); + + it('applies ARIA attributes', async () => { + const wrapper = mount(VModal, { + props: { modelValue: true, title: 'ARIA Test', idBase: 'myModal' }, + slots: { default: 'Description
' }, + attachTo: document.body + }); + await nextTick(); + const container = wrapper.find('.modal-container'); + expect(container.attributes('role')).toBe('dialog'); + expect(container.attributes('aria-modal')).toBe('true'); + expect(container.attributes('aria-labelledby')).toBe('myModal-title'); + expect(container.attributes('aria-describedby')).toBe('myModal-description'); + expect(wrapper.find('#myModal-title').exists()).toBe(true); + expect(wrapper.find('#myModal-description').exists()).toBe(true); + }); + + it('generates unique IDs if idBase is not provided', async () => { + const wrapper = mount(VModal, { + props: { modelValue: true, title: 'ARIA Test' }, + slots: { default: 'Description
' }, + attachTo: document.body + }); + await nextTick(); + const titleId = wrapper.find('.modal-title').attributes('id'); + const bodyId = wrapper.find('.modal-body').attributes('id'); + expect(titleId).toMatch(/^modal-.+-title$/); + expect(bodyId).toMatch(/^modal-.+-description$/); + expect(wrapper.find('.modal-container').attributes('aria-labelledby')).toBe(titleId); + expect(wrapper.find('.modal-container').attributes('aria-describedby')).toBe(bodyId); + }); + + + it('toggles body class "modal-open"', async () => { + const wrapper = mount(VModal, { + props: { modelValue: false }, // Start closed + attachTo: document.body + }); + expect(document.body.classList.contains('modal-open')).toBe(false); + + await wrapper.setProps({ modelValue: true }); + await nextTick(); + expect(document.body.classList.contains('modal-open')).toBe(true); + + await wrapper.setProps({ modelValue: false }); + await nextTick(); + expect(document.body.classList.contains('modal-open')).toBe(false); + }); + + it('emits opened event after transition enter', async () => { + const wrapper = mount(VModal, { props: { modelValue: false }, attachTo: document.body }); + await wrapper.setProps({ modelValue: true }); + await nextTick(); // Start opening + // Manually trigger after-enter for transition if not automatically handled by JSDOM + // In a real browser, this is async. In test, might need to simulate. + // Vue Test Utils sometimes requires manual control over transitions. + // If Transition component is stubbed or not fully supported in test env, + // this might need a different approach or direct call to handler. + + // For now, assume transition events work or component calls it directly. + // We can directly call the handler for testing the emit. + wrapper.vm.onOpened(); // Manually call the method that emits + expect(wrapper.emitted().opened).toBeTruthy(); + }); + + it('emits closed event after transition leave', async () => { + const wrapper = mount(VModal, { props: { modelValue: true }, attachTo: document.body }); + await nextTick(); // Is open + await wrapper.setProps({ modelValue: false }); + await nextTick(); // Start closing + + wrapper.vm.onClosed(); // Manually call the method that emits + expect(wrapper.emitted().closed).toBeTruthy(); + }); +}); diff --git a/fe/src/components/valerie/VModal.stories.ts b/fe/src/components/valerie/VModal.stories.ts new file mode 100644 index 0000000..15f9763 --- /dev/null +++ b/fe/src/components/valerie/VModal.stories.ts @@ -0,0 +1,275 @@ +import VModal from './VModal.vue'; +import VButton from './VButton.vue'; // For modal footer actions +import VInput from './VInput.vue'; // For form elements in modal +import VFormField from './VFormField.vue'; // For form layout in modal +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref, watch } from 'vue'; + +const meta: Meta{{ args.bodyContent }}
+Try to close this modal with text in the input field.
+No unsaved changes. Modal will close normally.
+Unsaved changes detected!
+ + +Selected: {{ selectedValue }}
+Selected: {{ selectedValue }}
+Profile Tab Content: Information about the user.
+ +Settings Tab Content: Configuration options.
+ +Billing Tab Content: Payment methods and history.
+This panel should not be reachable if the tab is truly disabled.
+Content for Alerts. Example of custom content in VTab.
+Content for Messages. Also has custom content in its VTab.
+Content for {{ tabId }} tab.
+Current active tab (v-model): {{ currentTab || 'None' }}
+Note: Storybook control for 'modelValue' can also change the tab.
+This is the content of the only tab panel.
+The `VTabs` `onMounted` logic should select this tab by default if no other tab is specified via `modelValue` or `initialTab`.
+Content 1
Content 2