From fc16f169b1154baa16fd1cda603ee0f8adc9d8a9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 31 May 2025 13:37:30 +0000 Subject: [PATCH] Jules was unable to complete the task in time. Please review the work done so far and provide feedback for Jules to continue. --- fe/src/components/valerie/.placeholder | 1 + fe/src/components/valerie/VAlert.spec.ts | 127 ++++++++ fe/src/components/valerie/VAlert.stories.ts | 245 +++++++++++++++ fe/src/components/valerie/VAlert.vue | 184 ++++++++++++ fe/src/components/valerie/VAvatar.spec.ts | 115 +++++++ fe/src/components/valerie/VAvatar.stories.ts | 159 ++++++++++ fe/src/components/valerie/VAvatar.vue | 92 ++++++ fe/src/components/valerie/VBadge.spec.ts | 61 ++++ fe/src/components/valerie/VBadge.stories.ts | 96 ++++++ fe/src/components/valerie/VBadge.vue | 107 +++++++ fe/src/components/valerie/VButton.spec.ts | 159 ++++++++++ fe/src/components/valerie/VButton.stories.ts | 153 ++++++++++ fe/src/components/valerie/VButton.vue | 207 +++++++++++++ fe/src/components/valerie/VCard.spec.ts | 140 +++++++++ fe/src/components/valerie/VCard.stories.ts | 164 ++++++++++ fe/src/components/valerie/VCard.vue | 160 ++++++++++ fe/src/components/valerie/VCheckbox.spec.ts | 94 ++++++ .../components/valerie/VCheckbox.stories.ts | 151 ++++++++++ fe/src/components/valerie/VCheckbox.vue | 146 +++++++++ fe/src/components/valerie/VFormField.spec.ts | 112 +++++++ .../components/valerie/VFormField.stories.ts | 135 +++++++++ fe/src/components/valerie/VFormField.vue | 65 ++++ fe/src/components/valerie/VIcon.spec.ts | 55 ++++ fe/src/components/valerie/VIcon.stories.ts | 51 ++++ fe/src/components/valerie/VIcon.vue | 62 ++++ fe/src/components/valerie/VInput.spec.ts | 120 ++++++++ fe/src/components/valerie/VInput.stories.ts | 202 +++++++++++++ fe/src/components/valerie/VInput.vue | 127 ++++++++ fe/src/components/valerie/VList.spec.ts | 54 ++++ fe/src/components/valerie/VList.stories.ts | 113 +++++++ fe/src/components/valerie/VList.vue | 31 ++ fe/src/components/valerie/VListItem.spec.ts | 116 +++++++ .../components/valerie/VListItem.stories.ts | 158 ++++++++++ fe/src/components/valerie/VListItem.vue | 256 ++++++++++++++++ fe/src/components/valerie/VModal.spec.ts | 283 ++++++++++++++++++ fe/src/components/valerie/VModal.stories.ts | 275 +++++++++++++++++ fe/src/components/valerie/VModal.vue | 245 +++++++++++++++ .../components/valerie/VProgressBar.spec.ts | 93 ++++++ .../valerie/VProgressBar.stories.ts | 166 ++++++++++ fe/src/components/valerie/VProgressBar.vue | 125 ++++++++ fe/src/components/valerie/VRadio.spec.ts | 129 ++++++++ fe/src/components/valerie/VRadio.stories.ts | 176 +++++++++++ fe/src/components/valerie/VRadio.vue | 165 ++++++++++ fe/src/components/valerie/VSelect.spec.ts | 132 ++++++++ fe/src/components/valerie/VSelect.stories.ts | 197 ++++++++++++ fe/src/components/valerie/VSelect.vue | 158 ++++++++++ fe/src/components/valerie/VTextarea.spec.ts | 117 ++++++++ .../components/valerie/VTextarea.stories.ts | 173 +++++++++++ fe/src/components/valerie/VTextarea.vue | 139 +++++++++ .../components/valerie/VToggleSwitch.spec.ts | 109 +++++++ .../valerie/VToggleSwitch.stories.ts | 138 +++++++++ fe/src/components/valerie/VToggleSwitch.vue | 184 ++++++++++++ .../components/valerie/tabs/Tabs.stories.ts | 206 +++++++++++++ fe/src/components/valerie/tabs/VTab.spec.ts | 108 +++++++ fe/src/components/valerie/tabs/VTab.vue | 99 ++++++ fe/src/components/valerie/tabs/VTabList.vue | 25 ++ .../valerie/tabs/VTabListAndPanels.spec.ts | 35 +++ .../components/valerie/tabs/VTabPanel.spec.ts | 72 +++++ fe/src/components/valerie/tabs/VTabPanel.vue | 55 ++++ fe/src/components/valerie/tabs/VTabPanels.vue | 25 ++ fe/src/components/valerie/tabs/VTabs.spec.ts | 135 +++++++++ fe/src/components/valerie/tabs/VTabs.vue | 94 ++++++ fe/src/components/valerie/tabs/types.ts | 10 + 63 files changed, 8086 insertions(+) create mode 100644 fe/src/components/valerie/.placeholder create mode 100644 fe/src/components/valerie/VAlert.spec.ts create mode 100644 fe/src/components/valerie/VAlert.stories.ts create mode 100644 fe/src/components/valerie/VAlert.vue create mode 100644 fe/src/components/valerie/VAvatar.spec.ts create mode 100644 fe/src/components/valerie/VAvatar.stories.ts create mode 100644 fe/src/components/valerie/VAvatar.vue create mode 100644 fe/src/components/valerie/VBadge.spec.ts create mode 100644 fe/src/components/valerie/VBadge.stories.ts create mode 100644 fe/src/components/valerie/VBadge.vue create mode 100644 fe/src/components/valerie/VButton.spec.ts create mode 100644 fe/src/components/valerie/VButton.stories.ts create mode 100644 fe/src/components/valerie/VButton.vue create mode 100644 fe/src/components/valerie/VCard.spec.ts create mode 100644 fe/src/components/valerie/VCard.stories.ts create mode 100644 fe/src/components/valerie/VCard.vue create mode 100644 fe/src/components/valerie/VCheckbox.spec.ts create mode 100644 fe/src/components/valerie/VCheckbox.stories.ts create mode 100644 fe/src/components/valerie/VCheckbox.vue create mode 100644 fe/src/components/valerie/VFormField.spec.ts create mode 100644 fe/src/components/valerie/VFormField.stories.ts create mode 100644 fe/src/components/valerie/VFormField.vue create mode 100644 fe/src/components/valerie/VIcon.spec.ts create mode 100644 fe/src/components/valerie/VIcon.stories.ts create mode 100644 fe/src/components/valerie/VIcon.vue create mode 100644 fe/src/components/valerie/VInput.spec.ts create mode 100644 fe/src/components/valerie/VInput.stories.ts create mode 100644 fe/src/components/valerie/VInput.vue create mode 100644 fe/src/components/valerie/VList.spec.ts create mode 100644 fe/src/components/valerie/VList.stories.ts create mode 100644 fe/src/components/valerie/VList.vue create mode 100644 fe/src/components/valerie/VListItem.spec.ts create mode 100644 fe/src/components/valerie/VListItem.stories.ts create mode 100644 fe/src/components/valerie/VListItem.vue create mode 100644 fe/src/components/valerie/VModal.spec.ts create mode 100644 fe/src/components/valerie/VModal.stories.ts create mode 100644 fe/src/components/valerie/VModal.vue create mode 100644 fe/src/components/valerie/VProgressBar.spec.ts create mode 100644 fe/src/components/valerie/VProgressBar.stories.ts create mode 100644 fe/src/components/valerie/VProgressBar.vue create mode 100644 fe/src/components/valerie/VRadio.spec.ts create mode 100644 fe/src/components/valerie/VRadio.stories.ts create mode 100644 fe/src/components/valerie/VRadio.vue create mode 100644 fe/src/components/valerie/VSelect.spec.ts create mode 100644 fe/src/components/valerie/VSelect.stories.ts create mode 100644 fe/src/components/valerie/VSelect.vue create mode 100644 fe/src/components/valerie/VTextarea.spec.ts create mode 100644 fe/src/components/valerie/VTextarea.stories.ts create mode 100644 fe/src/components/valerie/VTextarea.vue create mode 100644 fe/src/components/valerie/VToggleSwitch.spec.ts create mode 100644 fe/src/components/valerie/VToggleSwitch.stories.ts create mode 100644 fe/src/components/valerie/VToggleSwitch.vue create mode 100644 fe/src/components/valerie/tabs/Tabs.stories.ts create mode 100644 fe/src/components/valerie/tabs/VTab.spec.ts create mode 100644 fe/src/components/valerie/tabs/VTab.vue create mode 100644 fe/src/components/valerie/tabs/VTabList.vue create mode 100644 fe/src/components/valerie/tabs/VTabListAndPanels.spec.ts create mode 100644 fe/src/components/valerie/tabs/VTabPanel.spec.ts create mode 100644 fe/src/components/valerie/tabs/VTabPanel.vue create mode 100644 fe/src/components/valerie/tabs/VTabPanels.vue create mode 100644 fe/src/components/valerie/tabs/VTabs.spec.ts create mode 100644 fe/src/components/valerie/tabs/VTabs.vue create mode 100644 fe/src/components/valerie/tabs/types.ts diff --git a/fe/src/components/valerie/.placeholder b/fe/src/components/valerie/.placeholder new file mode 100644 index 0000000..5cce86c --- /dev/null +++ b/fe/src/components/valerie/.placeholder @@ -0,0 +1 @@ +# This is a placeholder file to create the directory. diff --git a/fe/src/components/valerie/VAlert.spec.ts b/fe/src/components/valerie/VAlert.spec.ts new file mode 100644 index 0000000..5a53ee8 --- /dev/null +++ b/fe/src/components/valerie/VAlert.spec.ts @@ -0,0 +1,127 @@ +import { mount } from '@vue/test-utils'; +import VAlert from './VAlert.vue'; +import VIcon from './VIcon.vue'; // VAlert uses VIcon +import { nextTick } from 'vue'; + +// Mock VIcon +vi.mock('./VIcon.vue', () => ({ + name: 'VIcon', + props: ['name'], + template: '', +})); + +describe('VAlert.vue', () => { + it('renders message prop when no default slot', () => { + const messageText = 'This is a test alert.'; + const wrapper = mount(VAlert, { props: { message: messageText } }); + expect(wrapper.find('.alert-content').text()).toBe(messageText); + }); + + it('renders default slot content instead of message prop', () => { + const slotContent = 'Custom Alert Content'; + const wrapper = mount(VAlert, { + props: { message: 'Ignored message' }, + slots: { default: slotContent }, + }); + expect(wrapper.find('.alert-content').html()).toContain(slotContent); + }); + + it('applies correct class based on type prop', () => { + const wrapperInfo = mount(VAlert, { props: { type: 'info' } }); + expect(wrapperInfo.classes()).toContain('alert-info'); + + const wrapperSuccess = mount(VAlert, { props: { type: 'success' } }); + expect(wrapperSuccess.classes()).toContain('alert-success'); + + const wrapperWarning = mount(VAlert, { props: { type: 'warning' } }); + expect(wrapperWarning.classes()).toContain('alert-warning'); + + const wrapperError = mount(VAlert, { props: { type: 'error' } }); + expect(wrapperError.classes()).toContain('alert-error'); + }); + + it('shows close button and emits events when closable is true', async () => { + const wrapper = mount(VAlert, { props: { closable: true, modelValue: true } }); + const closeButton = wrapper.find('.alert-close-btn'); + expect(closeButton.exists()).toBe(true); + + await closeButton.trigger('click'); + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual([false]); + expect(wrapper.emitted()['close']).toBeTruthy(); + + // Check if alert is hidden after internalModelValue updates (due to transition) + await nextTick(); // Allow internalModelValue to update and transition to start + // Depending on how transition is handled, the element might still be in DOM but display:none + // or completely removed. VAlert uses v-if, so it should be removed. + // Forcing internalModelValue directly for test simplicity if needed, or wait for transition. + // wrapper.vm.internalModelValue = false; // If directly manipulating for test + // await nextTick(); + expect(wrapper.find('.alert').exists()).toBe(false); // After click and model update, it should be gone + }); + + it('does not show close button when closable is false (default)', () => { + const wrapper = mount(VAlert); + expect(wrapper.find('.alert-close-btn').exists()).toBe(false); + }); + + it('displays icon by default and uses type-specific default icons', () => { + const wrapperSuccess = mount(VAlert, { props: { type: 'success' } }); + expect(wrapperSuccess.find('.alert-icon').exists()).toBe(true); + expect(wrapperSuccess.find('.icon-check-circle').exists()).toBe(true); // Mocked VIcon class + + const wrapperError = mount(VAlert, { props: { type: 'error' } }); + expect(wrapperError.find('.icon-alert-octagon').exists()).toBe(true); + }); + + it('displays custom icon when icon prop is provided', () => { + const customIconName = 'custom-bell'; + const wrapper = mount(VAlert, { props: { icon: customIconName } }); + expect(wrapper.find('.alert-icon').exists()).toBe(true); + expect(wrapper.find(`.icon-${customIconName}`).exists()).toBe(true); + }); + + it('does not display icon when showIcon is false', () => { + const wrapper = mount(VAlert, { props: { showIcon: false } }); + expect(wrapper.find('.alert-icon').exists()).toBe(false); + }); + + it('renders actions slot content', () => { + const actionsContent = ''; + const wrapper = mount(VAlert, { + slots: { actions: actionsContent }, + }); + const actionsDiv = wrapper.find('.alert-actions'); + expect(actionsDiv.exists()).toBe(true); + expect(actionsDiv.html()).toContain(actionsContent); + }); + + it('does not render .alert-actions if slot is not provided', () => { + const wrapper = mount(VAlert); + expect(wrapper.find('.alert-actions').exists()).toBe(false); + }); + + it('is visible by default (modelValue true)', () => { + const wrapper = mount(VAlert); + expect(wrapper.find('.alert').exists()).toBe(true); + }); + + it('is hidden when modelValue is initially false', () => { + const wrapper = mount(VAlert, { props: { modelValue: false } }); + expect(wrapper.find('.alert').exists()).toBe(false); + }); + + it('updates visibility when modelValue prop changes', async () => { + const wrapper = mount(VAlert, { props: { modelValue: true } }); + expect(wrapper.find('.alert').exists()).toBe(true); + + await wrapper.setProps({ modelValue: false }); + // Wait for transition if any, or internalModelValue update + await nextTick(); + expect(wrapper.find('.alert').exists()).toBe(false); + + await wrapper.setProps({ modelValue: true }); + await nextTick(); + expect(wrapper.find('.alert').exists()).toBe(true); + }); +}); diff --git a/fe/src/components/valerie/VAlert.stories.ts b/fe/src/components/valerie/VAlert.stories.ts new file mode 100644 index 0000000..3ace102 --- /dev/null +++ b/fe/src/components/valerie/VAlert.stories.ts @@ -0,0 +1,245 @@ +import VAlert from './VAlert.vue'; +import VIcon from './VIcon.vue'; // Used by VAlert +import VButton from './VButton.vue'; // For actions slot +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref, watch } from 'vue'; + +const meta: Meta = { + title: 'Valerie/VAlert', + component: VAlert, + tags: ['autodocs'], + argTypes: { + modelValue: { control: 'boolean', description: 'Controls alert visibility (v-model).' }, + message: { control: 'text' }, + type: { control: 'select', options: ['info', 'success', 'warning', 'error'] }, + closable: { control: 'boolean' }, + icon: { control: 'text', description: 'Custom VIcon name. Overrides default type-based icon.' }, + showIcon: { control: 'boolean' }, + // Events + 'update:modelValue': { action: 'update:modelValue', table: { disable: true } }, + close: { action: 'close' }, + // Slots + default: { table: { disable: true } }, + actions: { table: { disable: true } }, + }, + parameters: { + docs: { + description: { + component: 'An alert component to display contextual messages. Supports different types, icons, closable behavior, and custom actions.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Template for managing v-model in stories for dismissible alerts +const DismissibleAlertTemplate: Story = { + render: (args) => ({ + components: { VAlert, VButton, VIcon }, // VIcon may not be needed directly in template if VAlert handles it + setup() { + const isVisible = ref(args.modelValue); + watch(() => args.modelValue, (newVal) => { + isVisible.value = newVal; + }); + + const onUpdateModelValue = (val: boolean) => { + isVisible.value = val; + // args.modelValue = val; // Update Storybook control + }; + + const resetAlert = () => { // Helper to show alert again in story + isVisible.value = true; + // args.modelValue = true; + }; + + return { ...args, isVisible, onUpdateModelValue, resetAlert }; + }, + template: ` +
+ + + + + + Show Alert Again + +
+ `, + }), +}; + +export const Info: Story = { + ...DismissibleAlertTemplate, + args: { + modelValue: true, + message: 'This is an informational alert. Check it out!', + type: 'info', + closable: false, + }, +}; + +export const Success: Story = { + ...DismissibleAlertTemplate, + args: { + modelValue: true, + message: 'Your operation was completed successfully.', + type: 'success', + closable: true, + }, +}; + +export const Warning: Story = { + ...DismissibleAlertTemplate, + args: { + modelValue: true, + message: 'Warning! Something might require your attention.', + type: 'warning', + closable: true, + }, +}; + +export const ErrorAlert: Story = { // Renamed from 'Error' to avoid conflict with JS Error type + ...DismissibleAlertTemplate, + args: { + modelValue: true, + message: 'An error occurred while processing your request.', + type: 'error', + closable: true, + }, +}; + +export const Closable: Story = { + ...DismissibleAlertTemplate, + args: { + ...Info.args, // Start with info type + closable: true, + message: 'This alert can be closed by clicking the "x" button.', + }, +}; + +export const WithCustomIcon: Story = { + ...DismissibleAlertTemplate, + args: { + ...Info.args, + icon: 'alert', // Example: using 'alert' icon from VIcon for an info message + message: 'This alert uses a custom icon ("alert").', + }, +}; + +export const NoIcon: Story = { + ...DismissibleAlertTemplate, + args: { + ...Info.args, + showIcon: false, + message: 'This alert is displayed without any icon.', + }, +}; + +export const CustomSlotContent: Story = { + ...DismissibleAlertTemplate, + render: (args) => ({ + components: { VAlert, VButton, VIcon }, + setup() { + const isVisible = ref(args.modelValue); + watch(() => args.modelValue, (newVal) => { isVisible.value = newVal; }); + const onUpdateModelValue = (val: boolean) => { isVisible.value = val; }; + const resetAlert = () => { isVisible.value = true; }; + return { ...args, isVisible, onUpdateModelValue, resetAlert }; + }, + template: ` +
+ +

Custom Title via Slot!

+

This is bold text and italic text inside the alert's default slot.

+

It overrides the 'message' prop.

+
+ + Show Alert Again + +
+ ` + }), + args: { + modelValue: true, + type: 'info', + closable: true, + customDefaultSlot: true, // Flag for template logic, not a prop of VAlert + // message prop is ignored due to slot + }, +}; + +export const WithActions: Story = { + ...DismissibleAlertTemplate, + render: (args) => ({ + components: { VAlert, VButton, VIcon }, + setup() { + const isVisible = ref(args.modelValue); + watch(() => args.modelValue, (newVal) => { isVisible.value = newVal; }); + const onUpdateModelValue = (val: boolean) => { isVisible.value = val;}; + const resetAlert = () => { isVisible.value = true; }; + return { ...args, isVisible, onUpdateModelValue, resetAlert }; + }, + template: ` +
+ + + + + Show Alert Again + +
+ ` + }), + args: { + modelValue: true, + message: 'This alert has actions associated with it.', + type: 'warning', + closable: false, // Actions slot provides its own dismiss usually + showActionsSlot: true, // Flag for template logic + }, +}; + +export const NotInitiallyVisible: Story = { + ...DismissibleAlertTemplate, + args: { + ...Success.args, + modelValue: false, // Start hidden + message: 'This alert is initially hidden and can be shown by the button below (or Storybook control).', + closable: true, + }, +}; diff --git a/fe/src/components/valerie/VAlert.vue b/fe/src/components/valerie/VAlert.vue new file mode 100644 index 0000000..da1f1b0 --- /dev/null +++ b/fe/src/components/valerie/VAlert.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/fe/src/components/valerie/VAvatar.spec.ts b/fe/src/components/valerie/VAvatar.spec.ts new file mode 100644 index 0000000..dccad6b --- /dev/null +++ b/fe/src/components/valerie/VAvatar.spec.ts @@ -0,0 +1,115 @@ +import { mount } from '@vue/test-utils'; +import VAvatar from './VAvatar.vue'; +import VIcon from './VIcon.vue'; // For testing slot content with an icon +import { describe, it, expect, vi } from 'vitest'; + +describe('VAvatar.vue', () => { + it('renders an image when src is provided', () => { + const src = 'https://via.placeholder.com/40'; + const wrapper = mount(VAvatar, { props: { src, alt: 'Test Alt' } }); + const img = wrapper.find('img'); + expect(img.exists()).toBe(true); + expect(img.attributes('src')).toBe(src); + expect(img.attributes('alt')).toBe('Test Alt'); + }); + + it('renders initials when src is not provided but initials are', () => { + const wrapper = mount(VAvatar, { props: { initials: 'JD' } }); + expect(wrapper.find('img').exists()).toBe(false); + const initialsSpan = wrapper.find('.avatar-initials'); + expect(initialsSpan.exists()).toBe(true); + expect(initialsSpan.text()).toBe('JD'); + }); + + it('renders slot content when src and initials are not provided', () => { + const wrapper = mount(VAvatar, { + slots: { + default: '', // Using VIcon for a more realistic slot + }, + global: { + components: { VIcon } // Register VIcon locally for this test + } + }); + expect(wrapper.find('img').exists()).toBe(false); + expect(wrapper.find('.avatar-initials').exists()).toBe(false); + const icon = wrapper.findComponent(VIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('user'); + }); + + it('renders initials if image fails to load and initials are provided', async () => { + const wrapper = mount(VAvatar, { + props: { src: 'invalid-image-url.jpg', initials: 'FL' }, + }); + const img = wrapper.find('img'); + expect(img.exists()).toBe(true); // Image still exists in DOM initially + + // Trigger the error event on the image + await img.trigger('error'); + + // Now it should render initials + expect(wrapper.find('img').exists()).toBe(false); // This depends on implementation (v-if vs display:none) + // Current VAvatar.vue removes img with v-if + const initialsSpan = wrapper.find('.avatar-initials'); + expect(initialsSpan.exists()).toBe(true); + expect(initialsSpan.text()).toBe('FL'); + }); + + it('renders slot content if image fails to load and no initials are provided but slot is', async () => { + const wrapper = mount(VAvatar, { + props: { src: 'another-invalid.jpg' }, + slots: { default: 'Fallback Slot' }, + }); + const img = wrapper.find('img'); + expect(img.exists()).toBe(true); + + await img.trigger('error'); + + expect(wrapper.find('img').exists()).toBe(false); // Assuming v-if removes it + expect(wrapper.find('.avatar-initials').exists()).toBe(false); + expect(wrapper.text()).toBe('Fallback Slot'); + }); + + it('renders nothing (or only .avatar div) if image fails, no initials, and no slot', async () => { + const wrapper = mount(VAvatar, { + props: { src: 'failure.jpg' }, + }); + const img = wrapper.find('img'); + expect(img.exists()).toBe(true); + await img.trigger('error'); + expect(wrapper.find('img').exists()).toBe(false); + expect(wrapper.find('.avatar-initials').exists()).toBe(false); + expect(wrapper.find('.avatar').element.innerHTML).toBe(''); // Check if it's empty + }); + + + it('applies the base avatar class', () => { + const wrapper = mount(VAvatar, { props: { initials: 'T' } }); + expect(wrapper.classes()).toContain('avatar'); + }); + + it('uses the alt prop for the image', () => { + const wrapper = mount(VAvatar, { props: { src: 'image.png', alt: 'My Avatar' } }); + expect(wrapper.find('img').attributes('alt')).toBe('My Avatar'); + }); + + it('defaults alt prop to "Avatar"', () => { + const wrapper = mount(VAvatar, { props: { src: 'image.png' } }); + expect(wrapper.find('img').attributes('alt')).toBe('Avatar'); + }); + + it('resets image error state when src changes', async () => { + const wrapper = mount(VAvatar, { + props: { src: 'invalid.jpg', initials: 'IE' } + }); + let img = wrapper.find('img'); + await img.trigger('error'); // Image fails, initials should show + expect(wrapper.find('.avatar-initials').exists()).toBe(true); + + await wrapper.setProps({ src: 'valid.jpg' }); // Change src to a new one + img = wrapper.find('img'); // Re-find img + expect(img.exists()).toBe(true); // Image should now be shown + expect(img.attributes('src')).toBe('valid.jpg'); + expect(wrapper.find('.avatar-initials').exists()).toBe(false); // Initials should be hidden + }); +}); diff --git a/fe/src/components/valerie/VAvatar.stories.ts b/fe/src/components/valerie/VAvatar.stories.ts new file mode 100644 index 0000000..c0afab1 --- /dev/null +++ b/fe/src/components/valerie/VAvatar.stories.ts @@ -0,0 +1,159 @@ +import VAvatar from './VAvatar.vue'; +import VIcon from './VIcon.vue'; // For slot example +import type { Meta, StoryObj } from '@storybook/vue3'; + +const meta: Meta = { + title: 'Valerie/VAvatar', + component: VAvatar, + tags: ['autodocs'], + argTypes: { + src: { + control: 'text', + description: 'URL to the avatar image. Invalid URLs will demonstrate fallback behavior if initials or slot are provided.', + }, + initials: { control: 'text' }, + alt: { control: 'text' }, + // Slot content is best demonstrated via render functions or template strings in individual stories + }, + parameters: { + docs: { + description: { + component: 'An avatar component that can display an image, initials, or custom content via a slot. Fallback order: src -> initials -> slot.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithImage: Story = { + args: { + // Using a placeholder image service. Replace with a valid image URL for testing. + src: 'https://via.placeholder.com/40x40.png?text=IMG', + alt: 'User Avatar', + }, +}; + +export const WithInitials: Story = { + args: { + initials: 'JD', + alt: 'User Initials', + }, +}; + +export const WithSlotContent: Story = { + render: (args) => ({ + components: { VAvatar, VIcon }, // VIcon for the example + setup() { + return { args }; + }, + template: ` + + + + `, + }), + args: { + alt: 'Custom Icon Avatar', + }, + parameters: { + docs: { + description: { + story: 'Avatar with custom content (e.g., an icon) passed through the default slot. This appears if `src` and `initials` are not provided or if `src` fails to load and `initials` are also absent.', + }, + }, + }, +}; + +export const ImageErrorFallbackToInitials: Story = { + args: { + src: 'https://invalid-url-that-will-definitely-fail.jpg', + initials: 'ER', + alt: 'Error Fallback', + }, + parameters: { + docs: { + description: { + story: 'Demonstrates fallback to initials when the image `src` is invalid or fails to load. The component attempts to load the image; upon error, it should display the initials.', + }, + }, + }, +}; + +export const ImageErrorFallbackToSlot: Story = { + render: (args) => ({ + components: { VAvatar, VIcon }, + setup() { return { args }; }, + template: ` + + + + `, + }), + args: { + src: 'https://another-invalid-url.png', + alt: 'Error Fallback to Slot', + // No initials provided, so it should fall back to slot content + }, + parameters: { + docs: { + description: { + story: 'Demonstrates fallback to slot content when `src` is invalid and `initials` are not provided.', + }, + }, + }, +}; + +export const OnlyInitialsNoSrc: Story = { + args: { + initials: 'AB', + alt: 'Initials Only', + }, +}; + +export const EmptyPropsUsesSlot: Story = { + render: (args) => ({ + components: { VAvatar }, + setup() { return { args }; }, + template: ` + + ? + + `, + }), + args: { + alt: 'Empty Avatar', + }, + parameters: { + docs: { + description: { + story: 'When `src` and `initials` are not provided, the content from the default slot is rendered.', + }, + }, + }, +}; + +export const LargerAvatarViaStyle: Story = { + args: { + initials: 'LG', + alt: 'Large Avatar', + }, + decorators: [() => ({ template: '
' })], + // This story assumes you might have CSS like: + // .avatar { width: var(--avatar-size, 40px); height: var(--avatar-size, 40px); } + // Or you'd pass a size prop if implemented. For now, it just wraps. + // A more direct approach for story: + render: (args) => ({ + components: { VAvatar }, + setup() { return { args }; }, + template: ``, + }), + parameters: { + docs: { + description: { + story: 'Avatars can be resized using standard CSS. The internal text/icon might also need adjustment for larger sizes if not handled by `em` units or percentages.', + }, + }, + }, +}; diff --git a/fe/src/components/valerie/VAvatar.vue b/fe/src/components/valerie/VAvatar.vue new file mode 100644 index 0000000..e6dd224 --- /dev/null +++ b/fe/src/components/valerie/VAvatar.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/fe/src/components/valerie/VBadge.spec.ts b/fe/src/components/valerie/VBadge.spec.ts new file mode 100644 index 0000000..069106c --- /dev/null +++ b/fe/src/components/valerie/VBadge.spec.ts @@ -0,0 +1,61 @@ +import { mount } from '@vue/test-utils'; +import VBadge from './VBadge.vue'; +import { describe, it, expect } from 'vitest'; + +describe('VBadge.vue', () => { + it('renders with required text prop and default variant', () => { + const wrapper = mount(VBadge, { props: { text: 'Test Badge' } }); + expect(wrapper.text()).toBe('Test Badge'); + expect(wrapper.classes()).toContain('item-badge'); + expect(wrapper.classes()).toContain('badge-secondary'); // Default variant + }); + + it('renders with specified variant', () => { + const wrapper = mount(VBadge, { + props: { text: 'Accent Badge', variant: 'accent' }, + }); + expect(wrapper.classes()).toContain('badge-accent'); + }); + + it('applies sticky class when variant is accent and sticky is true', () => { + const wrapper = mount(VBadge, { + props: { text: 'Sticky Accent', variant: 'accent', sticky: true }, + }); + expect(wrapper.classes()).toContain('badge-sticky'); + }); + + it('does not apply sticky class when sticky is true but variant is not accent', () => { + const wrapper = mount(VBadge, { + props: { text: 'Sticky Secondary', variant: 'secondary', sticky: true }, + }); + expect(wrapper.classes()).not.toContain('badge-sticky'); + }); + + it('does not apply sticky class when variant is accent but sticky is false', () => { + const wrapper = mount(VBadge, { + props: { text: 'Non-sticky Accent', variant: 'accent', sticky: false }, + }); + expect(wrapper.classes()).not.toContain('badge-sticky'); + }); + + it('validates the variant prop', () => { + const validator = VBadge.props.variant.validator; + expect(validator('secondary')).toBe(true); + expect(validator('accent')).toBe(true); + expect(validator('settled')).toBe(true); + expect(validator('pending')).toBe(true); + expect(validator('invalid-variant')).toBe(false); + }); + + // Test for required prop 'text' (Vue Test Utils will warn if not provided) + it('Vue Test Utils should warn if required prop text is missing', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Mount without the required 'text' prop + // @ts-expect-error testing missing required prop + mount(VBadge, { props: { variant: 'accent'} }); + // Check if Vue's warning about missing required prop was logged + // This depends on Vue's warning messages and might need adjustment + expect(spy.mock.calls.some(call => call[0].includes('[Vue warn]: Missing required prop: "text"'))).toBe(true); + spy.mockRestore(); + }); +}); diff --git a/fe/src/components/valerie/VBadge.stories.ts b/fe/src/components/valerie/VBadge.stories.ts new file mode 100644 index 0000000..6c6bf3f --- /dev/null +++ b/fe/src/components/valerie/VBadge.stories.ts @@ -0,0 +1,96 @@ +import VBadge from './VBadge.vue'; +import type { Meta, StoryObj } from '@storybook/vue3'; + +const meta: Meta = { + title: 'Valerie/VBadge', + component: VBadge, + tags: ['autodocs'], + argTypes: { + text: { control: 'text' }, + variant: { + control: 'select', + options: ['secondary', 'accent', 'settled', 'pending'], + }, + sticky: { control: 'boolean' }, + }, + parameters: { + // Optional: Add notes about sticky behavior if it requires parent positioning + notes: 'The `sticky` prop adds a `badge-sticky` class when the variant is `accent`. Actual sticky positioning (e.g., `position: absolute` or `position: sticky`) should be handled by the parent component or additional global styles if needed, as the component itself does not enforce absolute positioning.', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Secondary: Story = { + args: { + text: 'Secondary', + variant: 'secondary', + }, +}; + +export const Accent: Story = { + args: { + text: 'Accent', + variant: 'accent', + }, +}; + +export const Settled: Story = { + args: { + text: 'Settled', + variant: 'settled', + }, +}; + +export const Pending: Story = { + args: { + text: 'Pending', + variant: 'pending', + }, +}; + +export const AccentSticky: Story = { + args: { + text: 'Sticky', + variant: 'accent', + sticky: true, + }, + // To demonstrate the intended sticky positioning from the design, + // we can wrap the component in a relatively positioned div for the story. + decorators: [ + () => ({ + template: ` +
+ Parent Element + +
+ `, + }), + ], + // parameters: { + // notes: 'This story demonstrates the sticky badge in a positioned context. The `badge-sticky` class itself only adds a border in this example. The absolute positioning shown (top: -4px, right: -4px relative to parent) needs to be applied by the parent or via styles targeting `.badge-sticky` in context.', + // } + // The following is a more direct way to show the absolute positioning if we add it to the component for the sticky case + // For now, the component itself doesn't add absolute positioning, so this shows how a parent might do it. + // If VBadge itself were to handle `position:absolute` when `sticky` and `variant=='accent'`, this would be different. +}; + +// Story to show that `sticky` prop only affects `accent` variant +export const StickyWithNonAccentVariant: Story = { + args: { + text: 'Not Sticky', + variant: 'secondary', // Not 'accent' + sticky: true, + }, + parameters: { + notes: 'The `sticky` prop only applies the `.badge-sticky` class (and its associated styles like a border) when the variant is `accent`. For other variants, `sticky: true` has no visual effect on the badge itself.', + } +}; + +export const LongText: Story = { + args: { + text: 'This is a very long badge text', + variant: 'primary', // Assuming primary is a valid variant or falls back to default + }, +}; diff --git a/fe/src/components/valerie/VBadge.vue b/fe/src/components/valerie/VBadge.vue new file mode 100644 index 0000000..e95ba2d --- /dev/null +++ b/fe/src/components/valerie/VBadge.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/fe/src/components/valerie/VButton.spec.ts b/fe/src/components/valerie/VButton.spec.ts new file mode 100644 index 0000000..272f3d5 --- /dev/null +++ b/fe/src/components/valerie/VButton.spec.ts @@ -0,0 +1,159 @@ +import { mount } from '@vue/test-utils'; +import VButton from './VButton.vue'; +import VIcon from './VIcon.vue'; // Import VIcon as it's a child component +import { describe, it, expect, vi } from 'vitest'; + +// Mock VIcon to simplify testing VButton in isolation if needed, +// or allow it to render if its behavior is simple and reliable. +// For now, we'll allow it to render as it's part of the visual output. + +describe('VButton.vue', () => { + it('renders with default props', () => { + const wrapper = mount(VButton); + expect(wrapper.text()).toBe('Button'); + expect(wrapper.classes()).toContain('btn'); + expect(wrapper.classes()).toContain('btn-primary'); + expect(wrapper.classes()).toContain('btn-md'); + expect(wrapper.attributes('type')).toBe('button'); + expect(wrapper.attributes('disabled')).toBeUndefined(); + }); + + it('renders with specified label', () => { + const wrapper = mount(VButton, { props: { label: 'Click Me' } }); + expect(wrapper.text()).toBe('Click Me'); + }); + + it('renders with slot content', () => { + const wrapper = mount(VButton, { + slots: { + default: 'Slot Content', + }, + }); + expect(wrapper.html()).toContain('Slot Content'); + }); + + it('applies variant classes', () => { + const wrapper = mount(VButton, { props: { variant: 'secondary' } }); + expect(wrapper.classes()).toContain('btn-secondary'); + }); + + it('applies size classes', () => { + const wrapper = mount(VButton, { props: { size: 'sm' } }); + expect(wrapper.classes()).toContain('btn-sm'); + }); + + it('is disabled when disabled prop is true', () => { + const wrapper = mount(VButton, { props: { disabled: true } }); + expect(wrapper.attributes('disabled')).toBeDefined(); + expect(wrapper.classes()).toContain('btn-disabled'); + }); + + it('emits click event when not disabled', async () => { + const handleClick = vi.fn(); + const wrapper = mount(VButton, { + attrs: { + onClick: handleClick, // For native event handling by test runner + } + }); + await wrapper.trigger('click'); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('emits click event via emits options when not disabled', async () => { + const wrapper = mount(VButton); + await wrapper.trigger('click'); + expect(wrapper.emitted().click).toBeTruthy(); + expect(wrapper.emitted().click.length).toBe(1); + }); + + it('does not emit click event when disabled', async () => { + const handleClick = vi.fn(); + const wrapper = mount(VButton, { + props: { disabled: true }, + attrs: { + onClick: handleClick, // For native event handling by test runner + } + }); + await wrapper.trigger('click'); + expect(handleClick).not.toHaveBeenCalled(); + + // Check emitted events from component as well + const wrapperEmitted = mount(VButton, { props: { disabled: true } }); + await wrapperEmitted.trigger('click'); + expect(wrapperEmitted.emitted().click).toBeUndefined(); + }); + + it('renders left icon', () => { + const wrapper = mount(VButton, { + props: { iconLeft: 'search' }, + // Global stubs or components might be needed if VIcon isn't registered globally for tests + global: { components: { VIcon } } + }); + const icon = wrapper.findComponent(VIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('search'); + expect(wrapper.text()).toContain('Button'); // Label should still be there + }); + + it('renders right icon', () => { + const wrapper = mount(VButton, { + props: { iconRight: 'alert' }, + global: { components: { VIcon } } + }); + const icon = wrapper.findComponent(VIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('alert'); + }); + + it('renders icon only button', () => { + const wrapper = mount(VButton, { + props: { iconLeft: 'close', iconOnly: true, label: 'Close' }, + global: { components: { VIcon } } + }); + const icon = wrapper.findComponent(VIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('close'); + expect(wrapper.classes()).toContain('btn-icon-only'); + // Label should be visually hidden but present for accessibility + const labelSpan = wrapper.find('span'); + expect(labelSpan.exists()).toBe(true); + expect(labelSpan.classes()).toContain('sr-only'); + expect(labelSpan.text()).toBe('Close'); + }); + + it('renders icon only button with iconRight', () => { + const wrapper = mount(VButton, { + props: { iconRight: 'search', iconOnly: true, label: 'Search' }, + global: { components: { VIcon } } + }); + const icon = wrapper.findComponent(VIcon); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('search'); + expect(wrapper.classes()).toContain('btn-icon-only'); + }); + + it('validates variant prop', () => { + const validator = VButton.props.variant.validator; + expect(validator('primary')).toBe(true); + expect(validator('secondary')).toBe(true); + expect(validator('neutral')).toBe(true); + expect(validator('danger')).toBe(true); + expect(validator('invalid-variant')).toBe(false); + }); + + it('validates size prop', () => { + const validator = VButton.props.size.validator; + expect(validator('sm')).toBe(true); + expect(validator('md')).toBe(true); + expect(validator('lg')).toBe(true); + expect(validator('xl')).toBe(false); + }); + + it('validates type prop', () => { + const validator = VButton.props.type.validator; + expect(validator('button')).toBe(true); + expect(validator('submit')).toBe(true); + expect(validator('reset')).toBe(true); + expect(validator('link')).toBe(false); + }); +}); diff --git a/fe/src/components/valerie/VButton.stories.ts b/fe/src/components/valerie/VButton.stories.ts new file mode 100644 index 0000000..3e8e3a9 --- /dev/null +++ b/fe/src/components/valerie/VButton.stories.ts @@ -0,0 +1,153 @@ +import VButton from './VButton.vue'; +import VIcon from './VIcon.vue'; // Import VIcon to ensure it's registered for stories if needed +import type { Meta, StoryObj } from '@storybook/vue3'; + +const meta: Meta = { + title: 'Valerie/VButton', + component: VButton, + tags: ['autodocs'], + argTypes: { + label: { control: 'text' }, + variant: { + control: 'select', + options: ['primary', 'secondary', 'neutral', 'danger'], + }, + size: { + control: 'radio', + options: ['sm', 'md', 'lg'], + }, + disabled: { control: 'boolean' }, + iconLeft: { + control: 'select', + options: [null, 'alert', 'search', 'close'], // Example icons + }, + iconRight: { + control: 'select', + options: [null, 'alert', 'search', 'close'], // Example icons + }, + iconOnly: { control: 'boolean' }, + type: { + control: 'select', + options: ['button', 'submit', 'reset'], + }, + // Slot content is not easily controllable via args table in the same way for default slot + // We can use render functions or template strings in stories for complex slot content. + }, + // Register VIcon globally for these stories if VButton doesn't always explicitly import/register it + // decorators: [() => ({ template: '' })], // This is one way, or ensure VButton registers it +}; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + label: 'Primary Button', + variant: 'primary', + }, +}; + +export const Secondary: Story = { + args: { + label: 'Secondary Button', + variant: 'secondary', + }, +}; + +export const Neutral: Story = { + args: { + label: 'Neutral Button', + variant: 'neutral', + }, +}; + +export const Danger: Story = { + args: { + label: 'Danger Button', + variant: 'danger', + }, +}; + +export const Small: Story = { + args: { + label: 'Small Button', + size: 'sm', + }, +}; + +export const Large: Story = { + args: { + label: 'Large Button', + size: 'lg', + }, +}; + +export const Disabled: Story = { + args: { + label: 'Disabled Button', + disabled: true, + }, +}; + +export const WithIconLeft: Story = { + args: { + label: 'Icon Left', + iconLeft: 'search', // Example icon + }, +}; + +export const WithIconRight: Story = { + args: { + label: 'Icon Right', + iconRight: 'alert', // Example icon + }, +}; + +export const IconOnly: Story = { + args: { + label: 'Search', // Label for accessibility, will be visually hidden + iconLeft: 'search', // Or iconRight + iconOnly: true, + ariaLabel: 'Search Action', // It's good practice to ensure an aria-label for icon-only buttons + }, +}; + +export const IconOnlySmall: Story = { + args: { + label: 'Close', + iconLeft: 'close', + iconOnly: true, + size: 'sm', + ariaLabel: 'Close Action', + }, +}; + + +export const WithCustomSlotContent: Story = { + render: (args) => ({ + components: { VButton, VIcon }, + setup() { + return { args }; + }, + template: ` + + Italic Text & + + `, + }), + args: { + variant: 'primary', + // label is ignored when slot is used + }, +}; + +export const AsSubmitButton: Story = { + args: { + label: 'Submit Form', + type: 'submit', + variant: 'primary', + iconLeft: 'alert', // Example, not typical for submit + }, + // You might want to add a form in the story to see it in action + // decorators: [() => ({ template: '
' })], +}; diff --git a/fe/src/components/valerie/VButton.vue b/fe/src/components/valerie/VButton.vue new file mode 100644 index 0000000..2f465e0 --- /dev/null +++ b/fe/src/components/valerie/VButton.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/fe/src/components/valerie/VCard.spec.ts b/fe/src/components/valerie/VCard.spec.ts new file mode 100644 index 0000000..8f96cbb --- /dev/null +++ b/fe/src/components/valerie/VCard.spec.ts @@ -0,0 +1,140 @@ +import { mount } from '@vue/test-utils'; +import VCard from './VCard.vue'; +import VIcon from './VIcon.vue'; // VCard uses VIcon +import { describe, it, expect, vi } from 'vitest'; + +// Mock VIcon to simplify testing VCard in isolation, +// especially if VIcon itself has complex rendering or external dependencies. +// vi.mock('./VIcon.vue', ()_ => ({ +// name: 'VIcon', +// props: ['name', 'size'], +// template: '', +// })); +// For now, let's allow it to render as its props are simple. + +describe('VCard.vue', () => { + // Default variant tests + describe('Default Variant', () => { + it('renders headerTitle when provided and no header slot', () => { + const headerText = 'My Card Header'; + const wrapper = mount(VCard, { props: { headerTitle: headerText } }); + const header = wrapper.find('.card-header'); + expect(header.exists()).toBe(true); + expect(header.find('.card-header-title').text()).toBe(headerText); + }); + + it('renders header slot content instead of headerTitle', () => { + const slotContent = '
Custom Header
'; + const wrapper = mount(VCard, { + props: { headerTitle: 'Ignored Title' }, + slots: { header: slotContent }, + }); + const header = wrapper.find('.card-header'); + expect(header.exists()).toBe(true); + expect(header.find('.custom-header').exists()).toBe(true); + expect(header.text()).toContain('Custom Header'); + expect(header.find('.card-header-title').exists()).toBe(false); + }); + + it('does not render .card-header if no headerTitle and no header slot', () => { + const wrapper = mount(VCard, { slots: { default: '

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: Meta = { + title: 'Valerie/VCard', + component: VCard, + tags: ['autodocs'], + argTypes: { + headerTitle: { control: 'text' }, + variant: { control: 'select', options: ['default', 'empty-state'] }, + emptyIcon: { control: 'text', if: { arg: 'variant', eq: 'empty-state' } }, + emptyTitle: { control: 'text', if: { arg: 'variant', eq: 'empty-state' } }, + emptyMessage: { control: 'text', if: { arg: 'variant', eq: 'empty-state' } }, + // Slots are documented via story examples + header: { table: { disable: true } }, + default: { table: { disable: true } }, + footer: { table: { disable: true } }, + 'empty-actions': { table: { disable: true } }, + }, + parameters: { + docs: { + description: { + component: 'A versatile card component with support for header, body, footer, and an empty state variant.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const DefaultWithAllSlots: Story = { + render: (args) => ({ + components: { VCard, VButton }, + setup() { + return { args }; + }, + template: ` + + + +

This is the main body content of the card. It can contain any HTML or Vue components.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+ + +
+ `, + }), + args: { + headerTitle: 'Card Title (prop)', + variant: 'default', + useCustomHeaderSlot: false, // Control for story to switch between prop and slot + useCustomFooterSlot: true, + }, +}; + +export const WithHeaderTitleAndFooterProp: Story = { + // This story will use headerTitle prop and a simple text footer via slot for demo + render: (args) => ({ + components: { VCard }, + setup() { return { args }; }, + template: ` + +

Card body content goes here.

+ +
+ `, + }), + args: { + headerTitle: 'Report Summary', + }, +}; + + +export const CustomHeaderAndFooterSlots: Story = { + ...DefaultWithAllSlots, // Reuses render function from DefaultWithAllSlots + args: { + headerTitle: 'This will be overridden by slot', // Prop will be ignored due to slot + variant: 'default', + useCustomHeaderSlot: true, + useCustomFooterSlot: true, + }, +}; + +export const BodyOnly: Story = { + render: (args) => ({ + components: { VCard }, + setup() { return { args }; }, + template: ` + +

This card only has body content. No header or footer will be rendered.

+

It's useful for simple information display.

+
+ `, + }), + args: {}, +}; + +export const HeaderAndBody: Story = { + render: (args) => ({ + components: { VCard }, + setup() { return { args }; }, + template: ` + +

This card has a header (via prop) and body content, but no footer.

+
+ `, + }), + args: { + headerTitle: 'User Profile', + }, +}; + +export const EmptyState: Story = { + render: (args) => ({ + components: { VCard, VIcon, VButton }, // VIcon is used internally by VCard + setup() { + return { args }; + }, + template: ` + + + + `, + }), + args: { + variant: 'empty-state', // Already set, but good for clarity + emptyIcon: 'search', // Example icon name, ensure VIcon supports it or it's mocked + emptyTitle: 'No Items Found', + emptyMessage: 'There are currently no items to display. Try adjusting your filters or add a new item.', + showEmptyActions: true, + }, +}; + +export const EmptyStateMinimal: Story = { + ...EmptyState, // Reuses render function + args: { + variant: 'empty-state', + emptyIcon: '', // No icon + emptyTitle: 'Nothing Here', + emptyMessage: 'This space is intentionally blank.', + showEmptyActions: false, // No actions + }, +}; diff --git a/fe/src/components/valerie/VCard.vue b/fe/src/components/valerie/VCard.vue new file mode 100644 index 0000000..37718b3 --- /dev/null +++ b/fe/src/components/valerie/VCard.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/fe/src/components/valerie/VCheckbox.spec.ts b/fe/src/components/valerie/VCheckbox.spec.ts new file mode 100644 index 0000000..47074e2 --- /dev/null +++ b/fe/src/components/valerie/VCheckbox.spec.ts @@ -0,0 +1,94 @@ +import { mount } from '@vue/test-utils'; +import VCheckbox from './VCheckbox.vue'; +import { describe, it, expect } from 'vitest'; + +describe('VCheckbox.vue', () => { + it('binds modelValue and emits update:modelValue correctly (v-model)', async () => { + const wrapper = mount(VCheckbox, { + props: { modelValue: false, id: 'test-check' }, // id is required due to default prop + }); + const inputElement = wrapper.find('input[type="checkbox"]'); + + // Check initial state (unchecked) + expect(inputElement.element.checked).toBe(false); + + // Simulate user checking the box + await inputElement.setChecked(true); + + // Check emitted event + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual([true]); + + // Simulate parent v-model update (checked) + await wrapper.setProps({ modelValue: true }); + expect(inputElement.element.checked).toBe(true); + + // Simulate user unchecking the box + await inputElement.setChecked(false); + expect(wrapper.emitted()['update:modelValue'][1]).toEqual([false]); + + // Simulate parent v-model update (unchecked) + await wrapper.setProps({ modelValue: false }); + expect(inputElement.element.checked).toBe(false); + }); + + it('renders label when label prop is provided', () => { + const labelText = 'Subscribe to newsletter'; + const wrapper = mount(VCheckbox, { + props: { modelValue: false, label: labelText, id: 'newsletter-check' }, + }); + const labelElement = wrapper.find('.checkbox-text-label'); + expect(labelElement.exists()).toBe(true); + expect(labelElement.text()).toBe(labelText); + }); + + it('does not render text label span when label prop is not provided', () => { + const wrapper = mount(VCheckbox, { + props: { modelValue: false, id: 'no-label-check' }, + }); + expect(wrapper.find('.checkbox-text-label').exists()).toBe(false); + }); + + it('is disabled when disabled prop is true', () => { + const wrapper = mount(VCheckbox, { + props: { modelValue: false, disabled: true, id: 'disabled-check' }, + }); + expect(wrapper.find('input[type="checkbox"]').attributes('disabled')).toBeDefined(); + expect(wrapper.find('.checkbox-label').classes()).toContain('disabled'); + }); + + it('is not disabled by default', () => { + const wrapper = mount(VCheckbox, { props: { modelValue: false, id: 'enabled-check' } }); + expect(wrapper.find('input[type="checkbox"]').attributes('disabled')).toBeUndefined(); + expect(wrapper.find('.checkbox-label').classes()).not.toContain('disabled'); + }); + + it('passes id prop to the input element and label for attribute', () => { + const checkboxId = 'my-custom-checkbox-id'; + const wrapper = mount(VCheckbox, { + props: { modelValue: false, id: checkboxId }, + }); + expect(wrapper.find('input[type="checkbox"]').attributes('id')).toBe(checkboxId); + expect(wrapper.find('.checkbox-label').attributes('for')).toBe(checkboxId); + }); + + it('generates an id if not provided', () => { + const wrapper = mount(VCheckbox, { props: { modelValue: false } }); + const inputId = wrapper.find('input[type="checkbox"]').attributes('id'); + expect(inputId).toBeDefined(); + expect(inputId).toContain('vcheckbox-'); + expect(wrapper.find('.checkbox-label').attributes('for')).toBe(inputId); + }); + + + it('contains a .checkmark span', () => { + const wrapper = mount(VCheckbox, { props: { modelValue: false, id: 'checkmark-check' } }); + expect(wrapper.find('.checkmark').exists()).toBe(true); + }); + + it('root element is a label with .checkbox-label class', () => { + const wrapper = mount(VCheckbox, { props: { modelValue: false, id: 'root-check' } }); + expect(wrapper.element.tagName).toBe('LABEL'); + expect(wrapper.classes()).toContain('checkbox-label'); + }); +}); diff --git a/fe/src/components/valerie/VCheckbox.stories.ts b/fe/src/components/valerie/VCheckbox.stories.ts new file mode 100644 index 0000000..ac4e5f5 --- /dev/null +++ b/fe/src/components/valerie/VCheckbox.stories.ts @@ -0,0 +1,151 @@ +import VCheckbox from './VCheckbox.vue'; +import VFormField from './VFormField.vue'; // For context, though checkbox usually handles its own label +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref, watch } from 'vue'; // For v-model in stories + +const meta: Meta = { + title: 'Valerie/VCheckbox', + component: VCheckbox, + tags: ['autodocs'], + argTypes: { + modelValue: { control: 'boolean', description: 'Bound state using v-model.' }, + label: { control: 'text' }, + disabled: { control: 'boolean' }, + id: { control: 'text' }, + // 'update:modelValue': { action: 'updated' } + }, + parameters: { + docs: { + description: { + component: 'A custom checkbox component with support for v-model, labels, and disabled states.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Template for v-model interaction in stories +const VModelTemplate: Story = { + render: (args) => ({ + components: { VCheckbox }, + setup() { + const storyValue = ref(args.modelValue); + watch(() => args.modelValue, (newVal) => { + storyValue.value = newVal; + }); + const onChange = (newValue: boolean) => { + storyValue.value = newValue; + // args.modelValue = newValue; // Storybook controls should update this + } + return { args, storyValue, onChange }; + }, + template: '', + }), +}; + +export const Basic: Story = { + ...VModelTemplate, + args: { + id: 'basicCheckbox', + modelValue: false, + }, +}; + +export const WithLabel: Story = { + ...VModelTemplate, + args: { + id: 'labelledCheckbox', + modelValue: true, + label: 'Accept terms and conditions', + }, +}; + +export const DisabledUnchecked: Story = { + ...VModelTemplate, + args: { + id: 'disabledUncheckedCheckbox', + modelValue: false, + label: 'Cannot select this', + disabled: true, + }, +}; + +export const DisabledChecked: Story = { + ...VModelTemplate, + args: { + id: 'disabledCheckedCheckbox', + modelValue: true, + label: 'Cannot unselect this', + disabled: true, + }, +}; + +export const NoLabel: Story = { + ...VModelTemplate, + args: { + id: 'noLabelCheckbox', + modelValue: true, + // No label prop + }, + parameters: { + docs: { + description: { story: 'Checkbox without a visible label prop. An external label can be associated using its `id`.' }, + }, + }, +}; + +// VCheckbox is usually self-contained with its label. +// Using it in VFormField might be less common unless VFormField is used for error messages only. +export const InFormFieldForError: Story = { + render: (args) => ({ + components: { VCheckbox, VFormField }, + setup() { + const storyValue = ref(args.checkboxArgs.modelValue); + watch(() => args.checkboxArgs.modelValue, (newVal) => { + storyValue.value = newVal; + }); + const onChange = (newValue: boolean) => { + storyValue.value = newValue; + } + // VFormField's label prop is not used here as VCheckbox has its own. + // VFormField's `forId` would match VCheckbox's `id`. + return { args, storyValue, onChange }; + }, + template: ` + + + + `, + }), + args: { + formFieldArgs: { + errorMessage: 'This selection is required.', + // No label for VFormField here, VCheckbox provides its own + }, + checkboxArgs: { + id: 'formFieldCheckbox', + modelValue: false, + label: 'I agree to the terms', + }, + }, + parameters: { + docs: { + description: { story: '`VCheckbox` used within `VFormField`, primarily for displaying an error message associated with the checkbox. VCheckbox manages its own label.' }, + }, + }, +}; + +export const PreChecked: Story = { + ...VModelTemplate, + args: { + id: 'preCheckedCheckbox', + modelValue: true, + label: 'This starts checked', + }, +}; diff --git a/fe/src/components/valerie/VCheckbox.vue b/fe/src/components/valerie/VCheckbox.vue new file mode 100644 index 0000000..b096b56 --- /dev/null +++ b/fe/src/components/valerie/VCheckbox.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/fe/src/components/valerie/VFormField.spec.ts b/fe/src/components/valerie/VFormField.spec.ts new file mode 100644 index 0000000..8e9ccf9 --- /dev/null +++ b/fe/src/components/valerie/VFormField.spec.ts @@ -0,0 +1,112 @@ +import { mount } from '@vue/test-utils'; +import VFormField from './VFormField.vue'; +import { describe, it, expect } from 'vitest'; + +// Simple placeholder for slotted input content in tests +const TestInputComponent = { + template: '', + props: ['id'], // Accept id if needed to match label's `for` +}; + +describe('VFormField.vue', () => { + it('renders default slot content', () => { + const wrapper = mount(VFormField, { + slots: { + default: '', + }, + }); + const input = wrapper.find('input[type="text"]'); + expect(input.exists()).toBe(true); + expect(input.attributes('id')).toBe('my-input'); + }); + + it('renders a label when label prop is provided', () => { + const labelText = 'Username'; + const wrapper = mount(VFormField, { + props: { label: labelText, forId: 'user-input' }, + slots: { default: '' } + }); + const label = wrapper.find('label'); + expect(label.exists()).toBe(true); + expect(label.text()).toBe(labelText); + expect(label.attributes('for')).toBe('user-input'); + expect(label.classes()).toContain('form-label'); + }); + + it('does not render a label when label prop is not provided', () => { + const wrapper = mount(VFormField, { + slots: { default: '' } + }); + expect(wrapper.find('label').exists()).toBe(false); + }); + + it('renders an error message when errorMessage prop is provided', () => { + const errorText = 'This field is required.'; + const wrapper = mount(VFormField, { + props: { errorMessage: errorText }, + slots: { default: '' } + }); + const errorMessage = wrapper.find('.form-error-message'); + expect(errorMessage.exists()).toBe(true); + expect(errorMessage.text()).toBe(errorText); + }); + + it('does not render an error message when errorMessage prop is not provided', () => { + const wrapper = mount(VFormField, { + slots: { default: '' } + }); + expect(wrapper.find('.form-error-message').exists()).toBe(false); + }); + + it('applies the forId prop to the label\'s "for" attribute', () => { + const inputId = 'email-field'; + const wrapper = mount(VFormField, { + props: { label: 'Email', forId: inputId }, + slots: { default: `` } + }); + const label = wrapper.find('label'); + expect(label.attributes('for')).toBe(inputId); + }); + + it('label "for" attribute is present even if forId is null or undefined, but empty', () => { + // Vue typically removes attributes if their value is null/undefined. + // Let's test the behavior. If forId is not provided, 'for' shouldn't be on the label. + const wrapperNull = mount(VFormField, { + props: { label: 'Test', forId: null }, + slots: { default: '' } + }); + const labelNull = wrapperNull.find('label'); + expect(labelNull.attributes('for')).toBeUndefined(); // Or it might be an empty string depending on Vue version/handling + + const wrapperUndefined = mount(VFormField, { + props: { label: 'Test' }, // forId is undefined + slots: { default: '' } + }); + const labelUndefined = wrapperUndefined.find('label'); + expect(labelUndefined.attributes('for')).toBeUndefined(); + }); + + + it('applies the .form-group class to the root element', () => { + const wrapper = mount(VFormField, { + slots: { default: '' } + }); + expect(wrapper.classes()).toContain('form-group'); + }); + + it('renders label, input, and error message all together', () => { + const wrapper = mount(VFormField, { + props: { + label: 'Password', + forId: 'pass', + errorMessage: 'Too short' + }, + slots: { + default: '' + } + }); + expect(wrapper.find('label').exists()).toBe(true); + expect(wrapper.find('input[type="password"]').exists()).toBe(true); + expect(wrapper.find('.form-error-message').exists()).toBe(true); + }); +}); diff --git a/fe/src/components/valerie/VFormField.stories.ts b/fe/src/components/valerie/VFormField.stories.ts new file mode 100644 index 0000000..06d09e7 --- /dev/null +++ b/fe/src/components/valerie/VFormField.stories.ts @@ -0,0 +1,135 @@ +import VFormField from './VFormField.vue'; +import type { Meta, StoryObj } from '@storybook/vue3'; + +// A simple placeholder input component for demonstration purposes in stories +const VInputPlaceholder = { + template: '', + props: ['id', 'placeholder'], +}; + + +const meta: Meta = { + title: 'Valerie/VFormField', + component: VFormField, + tags: ['autodocs'], + argTypes: { + label: { control: 'text' }, + forId: { control: 'text', description: 'ID of the input element this label is for. Should match the id of the slotted input.' }, + errorMessage: { control: 'text' }, + // Default slot is not directly configurable via args table in a simple way, + // so we use render functions or template strings in stories. + }, + parameters: { + docs: { + description: { + component: 'A wrapper component to structure form fields with a label, the input element itself (via slot), and an optional error message.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithLabelAndInput: Story = { + render: (args) => ({ + components: { VFormField, VInputPlaceholder }, + setup() { + return { args }; + }, + template: ` + + + + `, + }), + args: { + label: 'Full Name', + forId: 'nameInput', // This should match the ID of the VInputPlaceholder + errorMessage: '', + }, +}; + +export const WithLabelInputAndError: Story = { + render: (args) => ({ + components: { VFormField, VInputPlaceholder }, + setup() { + return { args }; + }, + template: ` + + + + `, + }), + args: { + label: 'Email Address', + forId: 'emailInput', + errorMessage: 'Please enter a valid email address.', + }, +}; + +export const InputOnlyNoError: Story = { + render: (args) => ({ + components: { VFormField, VInputPlaceholder }, + setup() { + return { args }; + }, + template: ` + + + + `, + }), + args: { + label: '', // No label + forId: '', + errorMessage: '', // No error + }, +}; + +export const InputWithErrorNoLabel: Story = { + render: (args) => ({ + components: { VFormField, VInputPlaceholder }, + setup() { + return { args }; + }, + template: ` + + + + `, + }), + args: { + label: '', + forId: '', + errorMessage: 'Password is required.', + }, +}; + +export const WithLabelNoErrorNoInputId: Story = { + render: (args) => ({ + components: { VFormField, VInputPlaceholder }, + setup() { + return { args }; + }, + template: ` + + + + + `, + }), + args: { + label: 'Description (Label `for` not connected)', + forId: 'unmatchedId', // For attribute will be present but might not point to a valid input + errorMessage: '', + }, + parameters: { + docs: { + description: { + story: "Demonstrates a label being present, but its `for` attribute might not link to the input if the input's ID is missing or doesn't match. This is valid but not ideal for accessibility.", + }, + }, + }, +}; diff --git a/fe/src/components/valerie/VFormField.vue b/fe/src/components/valerie/VFormField.vue new file mode 100644 index 0000000..102c196 --- /dev/null +++ b/fe/src/components/valerie/VFormField.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/fe/src/components/valerie/VIcon.spec.ts b/fe/src/components/valerie/VIcon.spec.ts new file mode 100644 index 0000000..1dbf24e --- /dev/null +++ b/fe/src/components/valerie/VIcon.spec.ts @@ -0,0 +1,55 @@ +import { mount } from '@vue/test-utils'; +import VIcon from './VIcon.vue'; +import { describe, it, expect } from 'vitest'; + +describe('VIcon.vue', () => { + it('renders the icon with the correct name class', () => { + const wrapper = mount(VIcon, { + props: { name: 'alert' }, + }); + expect(wrapper.classes()).toContain('icon'); + expect(wrapper.classes()).toContain('icon-alert'); + }); + + it('renders the icon with the correct size class when size is provided', () => { + const wrapper = mount(VIcon, { + props: { name: 'search', size: 'sm' }, + }); + expect(wrapper.classes()).toContain('icon-sm'); + }); + + it('renders the icon without a size class when size is not provided', () => { + const wrapper = mount(VIcon, { + props: { name: 'close' }, + }); + expect(wrapper.classes().find(cls => cls.startsWith('icon-sm') || cls.startsWith('icon-lg'))).toBeUndefined(); + }); + + it('renders nothing if name is not provided (due to required prop)', () => { + // Vue Test Utils might log a warning about missing required prop, which is expected. + // We are testing the component's behavior in such a scenario. + // Depending on error handling, it might render an empty tag or nothing. + // Here, we assume it renders the tag due to the template structure. + const wrapper = mount(VIcon, { + // @ts-expect-error testing missing required prop + props: { size: 'lg' }, + }); + // It will still render the tag, but without the icon-name class if `name` is truly not passed. + // However, Vue's prop validation will likely prevent mounting or cause errors. + // For a robust test, one might check for console warnings or specific error handling. + // Given the current setup, it will have 'icon' but not 'icon-undefined' or similar. + expect(wrapper.find('i').exists()).toBe(true); + // It should not have an `icon-undefined` or similar class if name is not passed. + // The behavior might depend on how Vue handles missing required props at runtime in test env. + // A more accurate test would be to check that the specific icon name class is NOT present. + expect(wrapper.classes().some(cls => cls.startsWith('icon-') && cls !== 'icon-sm' && cls !== 'icon-lg')).toBe(false); + }); + + it('validates the size prop', () => { + const validator = VIcon.props.size.validator; + expect(validator('sm')).toBe(true); + expect(validator('lg')).toBe(true); + expect(validator('md')).toBe(false); + expect(validator('')).toBe(false); + }); +}); diff --git a/fe/src/components/valerie/VIcon.stories.ts b/fe/src/components/valerie/VIcon.stories.ts new file mode 100644 index 0000000..52afcc0 --- /dev/null +++ b/fe/src/components/valerie/VIcon.stories.ts @@ -0,0 +1,51 @@ +import VIcon from './VIcon.vue'; +import type { Meta, StoryObj } from '@storybook/vue3'; + +const meta: Meta = { + title: 'Valerie/VIcon', + component: VIcon, + tags: ['autodocs'], + argTypes: { + name: { + control: 'select', + options: ['alert', 'search', 'close'], // Example icon names + }, + size: { + control: 'radio', + options: ['sm', 'lg', undefined], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + name: 'alert', + }, +}; + +export const Small: Story = { + args: { + name: 'search', + size: 'sm', + }, +}; + +export const Large: Story = { + args: { + name: 'close', + size: 'lg', + }, +}; + +export const CustomName: Story = { + args: { + name: 'custom-icon-name', // This will need a corresponding CSS class + }, + // Add a note about needing CSS for custom icons if not handled by a library + parameters: { + notes: 'This story uses a custom icon name. Ensure that a corresponding CSS class (e.g., .icon-custom-icon-name) is defined in VIcon.scss or your global icon styles for the icon to be visible.', + }, +}; diff --git a/fe/src/components/valerie/VIcon.vue b/fe/src/components/valerie/VIcon.vue new file mode 100644 index 0000000..80cb146 --- /dev/null +++ b/fe/src/components/valerie/VIcon.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/fe/src/components/valerie/VInput.spec.ts b/fe/src/components/valerie/VInput.spec.ts new file mode 100644 index 0000000..034fd33 --- /dev/null +++ b/fe/src/components/valerie/VInput.spec.ts @@ -0,0 +1,120 @@ +import { mount } from '@vue/test-utils'; +import VInput from './VInput.vue'; +import { describe, it, expect } from 'vitest'; + +describe('VInput.vue', () => { + it('binds modelValue and emits update:modelValue correctly (v-model)', async () => { + const wrapper = mount(VInput, { + props: { modelValue: 'initial text' }, + }); + const inputElement = wrapper.find('input'); + + // Check initial value + expect(inputElement.element.value).toBe('initial text'); + + // Simulate user input + await inputElement.setValue('new text'); + + // Check emitted event + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['new text']); + + // Check that prop update (simulating parent v-model update) changes the value + await wrapper.setProps({ modelValue: 'updated from parent' }); + expect(inputElement.element.value).toBe('updated from parent'); + }); + + it('sets the input type correctly', () => { + const wrapper = mount(VInput, { + props: { modelValue: '', type: 'password' }, + }); + expect(wrapper.find('input').attributes('type')).toBe('password'); + }); + + it('defaults type to "text" if not provided', () => { + const wrapper = mount(VInput, { props: { modelValue: '' } }); + expect(wrapper.find('input').attributes('type')).toBe('text'); + }); + + it('applies placeholder when provided', () => { + const placeholderText = 'Enter here'; + const wrapper = mount(VInput, { + props: { modelValue: '', placeholder: placeholderText }, + }); + expect(wrapper.find('input').attributes('placeholder')).toBe(placeholderText); + }); + + it('is disabled when disabled prop is true', () => { + const wrapper = mount(VInput, { + props: { modelValue: '', disabled: true }, + }); + expect(wrapper.find('input').attributes('disabled')).toBeDefined(); + }); + + it('is not disabled by default', () => { + const wrapper = mount(VInput, { props: { modelValue: '' } }); + expect(wrapper.find('input').attributes('disabled')).toBeUndefined(); + }); + + it('is required when required prop is true', () => { + const wrapper = mount(VInput, { + props: { modelValue: '', required: true }, + }); + expect(wrapper.find('input').attributes('required')).toBeDefined(); + }); + + it('is not required by default', () => { + const wrapper = mount(VInput, { props: { modelValue: '' } }); + expect(wrapper.find('input').attributes('required')).toBeUndefined(); + }); + + it('applies error class when error prop is true', () => { + const wrapper = mount(VInput, { + props: { modelValue: '', error: true }, + }); + expect(wrapper.find('input').classes()).toContain('form-input'); + expect(wrapper.find('input').classes()).toContain('error'); + }); + + it('does not apply error class by default or when error is false', () => { + const wrapperDefault = mount(VInput, { props: { modelValue: '' } }); + expect(wrapperDefault.find('input').classes()).toContain('form-input'); + expect(wrapperDefault.find('input').classes()).not.toContain('error'); + + const wrapperFalse = mount(VInput, { + props: { modelValue: '', error: false }, + }); + expect(wrapperFalse.find('input').classes()).toContain('form-input'); + expect(wrapperFalse.find('input').classes()).not.toContain('error'); + }); + + it('sets aria-invalid attribute when error prop is true', () => { + const wrapper = mount(VInput, { + props: { modelValue: '', error: true }, + }); + expect(wrapper.find('input').attributes('aria-invalid')).toBe('true'); + }); + + it('does not set aria-invalid attribute by default or when error is false', () => { + const wrapperDefault = mount(VInput, { props: { modelValue: '' } }); + expect(wrapperDefault.find('input').attributes('aria-invalid')).toBeNull(); // Or undefined + + const wrapperFalse = mount(VInput, { + props: { modelValue: '', error: false }, + }); + expect(wrapperFalse.find('input').attributes('aria-invalid')).toBeNull(); // Or undefined + }); + + it('passes id prop to the input element', () => { + const inputId = 'my-custom-id'; + const wrapper = mount(VInput, { + props: { modelValue: '', id: inputId }, + }); + expect(wrapper.find('input').attributes('id')).toBe(inputId); + }); + + it('does not have an id attribute if id prop is not provided', () => { + const wrapper = mount(VInput, { props: { modelValue: '' } }); + expect(wrapper.find('input').attributes('id')).toBeUndefined(); + }); +}); diff --git a/fe/src/components/valerie/VInput.stories.ts b/fe/src/components/valerie/VInput.stories.ts new file mode 100644 index 0000000..31b2c9d --- /dev/null +++ b/fe/src/components/valerie/VInput.stories.ts @@ -0,0 +1,202 @@ +import VInput from './VInput.vue'; +import VFormField from './VFormField.vue'; // To demonstrate usage with VFormField +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref } from 'vue'; // For v-model in stories + +const meta: Meta = { + title: 'Valerie/VInput', + component: VInput, + tags: ['autodocs'], + argTypes: { + modelValue: { control: 'text', description: 'Bound value using v-model.' }, // Or 'object' if number is frequent + type: { + control: 'select', + options: ['text', 'email', 'password', 'number', 'tel', 'url', 'search', 'date'], + }, + placeholder: { control: 'text' }, + disabled: { control: 'boolean' }, + required: { control: 'boolean' }, + error: { control: 'boolean', description: 'Applies error styling.' }, + id: { control: 'text' }, + // 'update:modelValue': { action: 'updated' } // To show event in actions tab + }, + parameters: { + docs: { + description: { + component: 'A versatile input component with support for various types, states, and v-model binding.', + }, + }, + }, + // Decorator to provide v-model functionality to stories if needed at a global level + // decorators: [ + // (story, context) => { + // const value = ref(context.args.modelValue || ''); + // return story({ ...context, args: { ...context.args, modelValue: value, 'onUpdate:modelValue': (val) => value.value = val } }); + // }, + // ], +}; + +export default meta; +type Story = StoryObj; + +// Template for v-model interaction in stories +const VModelTemplate: Story = { + render: (args) => ({ + components: { VInput }, + setup() { + // Storybook provides a mechanism to bind args, which includes modelValue. + // For direct v-model usage in the template, we might need a local ref. + // However, Storybook 7+ handles args updates automatically for controls. + // If direct v-model="args.modelValue" doesn't work due to arg immutability, + // use a local ref and update args on change. + const storyValue = ref(args.modelValue || ''); + const onInput = (newValue: string | number) => { + storyValue.value = newValue; + // args.modelValue = newValue; // This might be needed if SB doesn't auto-update + // For Storybook actions tab: + // context.emit('update:modelValue', newValue); + } + return { args, storyValue, onInput }; + }, + // Note: Storybook's `args` are reactive. `v-model="args.modelValue"` might work directly in some SB versions. + // Using a local ref `storyValue` and emitting an action is a robust way. + template: '', + }), +}; + +export const Basic: Story = { + ...VModelTemplate, + args: { + id: 'basicInput', + modelValue: 'Hello Valerie', + }, +}; + +export const WithPlaceholder: Story = { + ...VModelTemplate, + args: { + id: 'placeholderInput', + placeholder: 'Enter text here...', + modelValue: '', + }, +}; + +export const Disabled: Story = { + ...VModelTemplate, + args: { + id: 'disabledInput', + modelValue: 'Cannot change this', + disabled: true, + }, +}; + +export const Required: Story = { + ...VModelTemplate, + args: { + id: 'requiredInput', + modelValue: '', + required: true, + placeholder: 'This field is required', + }, + parameters: { + docs: { + description: { story: 'The `required` attribute is set. Form submission behavior depends on the browser and form context.' }, + }, + }, +}; + +export const ErrorState: Story = { + ...VModelTemplate, + args: { + id: 'errorInput', + modelValue: 'Incorrect value', + error: true, + }, +}; + +export const PasswordType: Story = { + ...VModelTemplate, + args: { + id: 'passwordInput', + type: 'password', + modelValue: 'secret123', + }, +}; + +export const EmailType: Story = { + ...VModelTemplate, + args: { + id: 'emailInput', + type: 'email', + modelValue: 'test@example.com', + placeholder: 'your.email@provider.com', + }, +}; + +export const NumberType: Story = { + ...VModelTemplate, + args: { + id: 'numberInput', + type: 'number', + modelValue: 42, + placeholder: 'Enter a number', + }, +}; + +// Story demonstrating VInput used within VFormField +export const InFormField: Story = { + render: (args) => ({ + components: { VInput, VFormField }, + setup() { + const storyValue = ref(args.inputArgs.modelValue || ''); + const onInput = (newValue: string | number) => { + storyValue.value = newValue; + // args.inputArgs.modelValue = newValue; // Update the nested arg for control sync + } + return { args, storyValue, onInput }; + }, + template: ` + + + + `, + }), + args: { + formFieldArgs: { + label: 'Your Name', + errorMessage: '', + }, + inputArgs: { + id: 'nameField', + modelValue: 'Initial Name', + placeholder: 'Enter your full name', + error: false, // Controlled by formFieldArgs.errorMessage typically + }, + }, + parameters: { + docs: { + description: { story: '`VInput` used inside a `VFormField`. The `id` on `VInput` should match `forId` on `VFormField`.' }, + }, + }, +}; + +export const InFormFieldWithError: Story = { + ...InFormField, // Inherit render function from InFormField + args: { + formFieldArgs: { + label: 'Your Email', + errorMessage: 'This email is invalid.', + }, + inputArgs: { + id: 'emailFieldWithError', + modelValue: 'invalid-email', + type: 'email', + placeholder: 'Enter your email', + error: true, // Set VInput's error state + }, + }, +}; diff --git a/fe/src/components/valerie/VInput.vue b/fe/src/components/valerie/VInput.vue new file mode 100644 index 0000000..6e57ac1 --- /dev/null +++ b/fe/src/components/valerie/VInput.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/fe/src/components/valerie/VList.spec.ts b/fe/src/components/valerie/VList.spec.ts new file mode 100644 index 0000000..9a3b4b2 --- /dev/null +++ b/fe/src/components/valerie/VList.spec.ts @@ -0,0 +1,54 @@ +import { mount } from '@vue/test-utils'; +import VList from './VList.vue'; +import VListItem from './VListItem.vue'; // For testing with children +import { describe, it, expect } from 'vitest'; + +describe('VList.vue', () => { + it('applies the .item-list class to the root element', () => { + const wrapper = mount(VList); + expect(wrapper.classes()).toContain('item-list'); + }); + + it('renders default slot content', () => { + const wrapper = mount(VList, { + slots: { + default: 'Item 1Item 2', + }, + global: { + components: { VListItem } // Register VListItem for the slot content + } + }); + const items = wrapper.findAllComponents(VListItem); + expect(items.length).toBe(2); + expect(wrapper.text()).toContain('Item 1'); + expect(wrapper.text()).toContain('Item 2'); + }); + + it('renders as a
    element by default', () => { + const wrapper = mount(VList); + expect(wrapper.element.tagName).toBe('UL'); + }); + + it('renders correctly when empty', () => { + const wrapper = mount(VList, { + slots: { default: '' } // Empty slot + }); + expect(wrapper.find('ul.item-list').exists()).toBe(true); + expect(wrapper.element.children.length).toBe(0); // No direct children from empty slot + // or use .html() to check inner content + expect(wrapper.html()).toContain('
      '); + + }); + + it('renders non-VListItem children if passed', () => { + const wrapper = mount(VList, { + slots: { + default: '
    • Raw LI
    • Just a div
      ' + } + }); + expect(wrapper.find('li').exists()).toBe(true); + expect(wrapper.find('div').exists()).toBe(true); + expect(wrapper.text()).toContain('Raw LI'); + expect(wrapper.text()).toContain('Just a div'); + }); +}); diff --git a/fe/src/components/valerie/VList.stories.ts b/fe/src/components/valerie/VList.stories.ts new file mode 100644 index 0000000..fff8015 --- /dev/null +++ b/fe/src/components/valerie/VList.stories.ts @@ -0,0 +1,113 @@ +import VList from './VList.vue'; +import VListItem from './VListItem.vue'; // VList will contain VListItems +import VBadge from './VBadge.vue'; // For complex VListItem content example +import VAvatar from './VAvatar.vue'; // For complex VListItem content example +import VButton from './VButton.vue'; // For swipe actions example +import type { Meta, StoryObj } from '@storybook/vue3'; + +const meta: Meta = { + title: 'Valerie/VList', + component: VList, + tags: ['autodocs'], + // No args for VList itself currently + parameters: { + docs: { + description: { + component: '`VList` is a container component for `VListItem` components or other list content. It applies basic list styling.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const DefaultWithItems: Story = { + render: (args) => ({ + components: { VList, VListItem, VBadge, VAvatar, VButton }, // Register all used components + setup() { + // Data for the list items + const items = ref([ + { id: 1, text: 'Pay utility bills', completed: false, swipable: true, isSwiped: false, avatar: 'https://via.placeholder.com/40x40.png?text=U', badgeText: 'Urgent', badgeVariant: 'danger' }, + { id: 2, text: 'Schedule doctor appointment', completed: true, swipable: false, avatar: 'https://via.placeholder.com/40x40.png?text=D', badgeText: 'Done', badgeVariant: 'settled' }, + { id: 3, text: 'Grocery shopping for the week', completed: false, swipable: true, isSwiped: false, avatar: 'https://via.placeholder.com/40x40.png?text=G', badgeText: 'Pending', badgeVariant: 'pending' }, + { id: 4, text: 'Book flight tickets for vacation', completed: false, swipable: false, avatar: 'https://via.placeholder.com/40x40.png?text=F' }, + ]); + + const toggleSwipe = (item) => { + item.isSwiped = !item.isSwiped; + }; + + const markComplete = (item) => { + item.completed = !item.completed; + } + + const deleteItem = (itemId) => { + items.value = items.value.filter(i => i.id !== itemId); + alert(`Item ${itemId} deleted (simulated)`); + } + + return { args, items, toggleSwipe, markComplete, deleteItem }; + }, + template: ` + + +
      + + {{ item.text }} + +
      + +
      + No items in the list. +
      + `, + }), + args: {}, +}; + +export const EmptyList: Story = { + render: (args) => ({ + components: { VList, VListItem }, + setup() { + return { args }; + }, + template: ` + + The list is currently empty. + + `, + }), + args: {}, + parameters: { + docs: { + description: { story: 'An example of an empty `VList`. It can contain a single `VListItem` with a message, or be programmatically emptied.' }, + }, + }, +}; + +export const ListWithSimpleTextItems: Story = { + render: (args) => ({ + components: { VList, VListItem }, + setup() { return { args }; }, + template: ` + + First item + Second item + Third item + + ` + }), + args: {} +}; diff --git a/fe/src/components/valerie/VList.vue b/fe/src/components/valerie/VList.vue new file mode 100644 index 0000000..989ae09 --- /dev/null +++ b/fe/src/components/valerie/VList.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/fe/src/components/valerie/VListItem.spec.ts b/fe/src/components/valerie/VListItem.spec.ts new file mode 100644 index 0000000..f14712c --- /dev/null +++ b/fe/src/components/valerie/VListItem.spec.ts @@ -0,0 +1,116 @@ +import { mount } from '@vue/test-utils'; +import VListItem from './VListItem.vue'; +import { describe, it, expect } from 'vitest'; + +// Mock VButton or other components if they are deeply tested and not relevant to VListItem's direct unit tests +// For example, if VButton has complex logic: +// vi.mock('./VButton.vue', () => ({ +// name: 'VButton', +// template: '' +// })); + +describe('VListItem.vue', () => { + it('renders default slot content in .list-item-content', () => { + const itemContent = 'Hello World'; + const wrapper = mount(VListItem, { + slots: { default: itemContent }, + }); + const contentDiv = wrapper.find('.list-item-content'); + expect(contentDiv.exists()).toBe(true); + expect(contentDiv.html()).toContain(itemContent); + }); + + it('applies .list-item class to the root element', () => { + const wrapper = mount(VListItem); + expect(wrapper.classes()).toContain('list-item'); + }); + + it('applies .completed class when completed prop is true', () => { + const wrapper = mount(VListItem, { props: { completed: true } }); + expect(wrapper.classes()).toContain('completed'); + }); + + it('does not apply .completed class when completed prop is false or default', () => { + const wrapperDefault = mount(VListItem); + expect(wrapperDefault.classes()).not.toContain('completed'); + + const wrapperFalse = mount(VListItem, { props: { completed: false } }); + expect(wrapperFalse.classes()).not.toContain('completed'); + }); + + it('applies .swipable class when swipable prop is true', () => { + const wrapper = mount(VListItem, { props: { swipable: true } }); + expect(wrapper.classes()).toContain('swipable'); + }); + + it('applies .is-swiped class when isSwiped and swipable props are true', () => { + const wrapper = mount(VListItem, { + props: { swipable: true, isSwiped: true }, + }); + expect(wrapper.classes()).toContain('is-swiped'); + }); + + it('does not apply .is-swiped class if swipable is false, even if isSwiped is true', () => { + const wrapper = mount(VListItem, { + props: { swipable: false, isSwiped: true }, + }); + expect(wrapper.classes()).not.toContain('is-swiped'); + }); + + it('does not apply .is-swiped class by default', () => { + const wrapper = mount(VListItem); + expect(wrapper.classes()).not.toContain('is-swiped'); + }); + + it('renders swipe-actions-right slot when swipable is true and slot has content', () => { + const actionsContent = ''; + const wrapper = mount(VListItem, { + props: { swipable: true }, + slots: { 'swipe-actions-right': actionsContent }, + }); + const actionsDiv = wrapper.find('.swipe-actions.swipe-actions-right'); + expect(actionsDiv.exists()).toBe(true); + expect(actionsDiv.html()).toContain(actionsContent); + }); + + it('does not render swipe-actions-right slot if swipable is false', () => { + const wrapper = mount(VListItem, { + props: { swipable: false }, + slots: { 'swipe-actions-right': '' }, + }); + expect(wrapper.find('.swipe-actions.swipe-actions-right').exists()).toBe(false); + }); + + it('does not render swipe-actions-right slot if swipable is true but slot has no content', () => { + const wrapper = mount(VListItem, { + props: { swipable: true }, + // No swipe-actions-right slot + }); + expect(wrapper.find('.swipe-actions.swipe-actions-right').exists()).toBe(false); + }); + + + it('renders swipe-actions-left slot when swipable is true and slot has content', () => { + const actionsContent = ''; + const wrapper = mount(VListItem, { + props: { swipable: true }, + slots: { 'swipe-actions-left': actionsContent }, + }); + const actionsDiv = wrapper.find('.swipe-actions.swipe-actions-left'); + expect(actionsDiv.exists()).toBe(true); + expect(actionsDiv.html()).toContain(actionsContent); + }); + + it('does not render swipe-actions-left slot if swipable is false', () => { + const wrapper = mount(VListItem, { + props: { swipable: false }, + slots: { 'swipe-actions-left': '' }, + }); + expect(wrapper.find('.swipe-actions.swipe-actions-left').exists()).toBe(false); + }); + + it('root element is an
    • by default', () => { + const wrapper = mount(VListItem); + expect(wrapper.element.tagName).toBe('LI'); + }); +}); diff --git a/fe/src/components/valerie/VListItem.stories.ts b/fe/src/components/valerie/VListItem.stories.ts new file mode 100644 index 0000000..822b788 --- /dev/null +++ b/fe/src/components/valerie/VListItem.stories.ts @@ -0,0 +1,158 @@ +import VListItem from './VListItem.vue'; +import VList from './VList.vue'; // For context +import VBadge from './VBadge.vue'; +import VAvatar from './VAvatar.vue'; +import VButton from './VButton.vue'; // For swipe actions +import VIcon from './VIcon.vue'; // For swipe actions +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref } from 'vue'; // For reactive props in stories + +const meta: Meta = { + title: 'Valerie/VListItem', + component: VListItem, + tags: ['autodocs'], + argTypes: { + completed: { control: 'boolean' }, + swipable: { control: 'boolean' }, + isSwiped: { control: 'boolean', description: 'Controls the visual swipe state (reveals actions). Requires `swipable` to be true.' }, + // Slots are demonstrated in individual stories + default: { table: { disable: true } }, + 'swipe-actions-right': { table: { disable: true } }, + 'swipe-actions-left': { table: { disable: true } }, + }, + decorators: [(story) => ({ components: { VList, story }, template: '' })], // Wrap stories in VList + parameters: { + docs: { + description: { + component: '`VListItem` represents an individual item in a `VList`. It supports various states like completed, swipable, and can contain complex content including swipe actions.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: (args) => ({ + components: { VListItem }, + setup() { return { args }; }, + template: 'Basic List Item', + }), + args: { + completed: false, + swipable: false, + isSwiped: false, + }, +}; + +export const Completed: Story = { + ...Basic, // Reuses render from Basic + args: { + ...Basic.args, + completed: true, + defaultSlotContent: 'This item is marked as completed.', + }, + // Need to adjust template if defaultSlotContent is used as a prop for story text + render: (args) => ({ + components: { VListItem }, + setup() { return { args }; }, + template: '{{ args.defaultSlotContent }}', + }), +}; + +export const Swipable: Story = { + render: (args) => ({ + components: { VListItem, VButton, VIcon }, + setup() { + // In a real app, isSwiped would be part of component's internal state or controlled by a swipe library. + // Here, we make it a reactive prop for the story to toggle. + const isSwipedState = ref(args.isSwiped); + const toggleSwipe = () => { + if (args.swipable) { + isSwipedState.value = !isSwipedState.value; + } + }; + return { args, isSwipedState, toggleSwipe }; + }, + template: ` + + {{ args.defaultSlotContent }} + + + + `, + }), + args: { + swipable: true, + isSwiped: false, // Initial state for the story control + completed: false, + defaultSlotContent: 'This item is swipable. Click to toggle swipe state for demo.', + }, +}; + +export const SwipedToShowActions: Story = { + ...Swipable, // Reuses render and setup from Swipable story + args: { + ...Swipable.args, + isSwiped: true, // Start in the "swiped" state + defaultSlotContent: 'This item is shown as already swiped (revealing right actions). Click to toggle.', + }, +}; + + +export const WithComplexContent: Story = { + render: (args) => ({ + components: { VListItem, VAvatar, VBadge }, + setup() { return { args }; }, + template: ` + +
      + +
      +
      {{ args.title }}
      +
      {{ args.subtitle }}
      +
      + +
      +
      + `, + }), + args: { + completed: false, + swipable: false, + isSwiped: false, + avatarSrc: 'https://via.placeholder.com/40x40.png?text=CX', + avatarInitials: 'CX', + title: 'Complex Item Title', + subtitle: 'Subtitle with additional information', + badgeText: 'New', + badgeVariant: 'accent', + }, +}; + +export const CompletedWithComplexContent: Story = { + ...WithComplexContent, // Reuses render from WithComplexContent + args: { + ...WithComplexContent.args, + completed: true, + badgeText: 'Finished', + badgeVariant: 'settled', + }, +}; diff --git a/fe/src/components/valerie/VListItem.vue b/fe/src/components/valerie/VListItem.vue new file mode 100644 index 0000000..66813b3 --- /dev/null +++ b/fe/src/components/valerie/VListItem.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/fe/src/components/valerie/VModal.spec.ts b/fe/src/components/valerie/VModal.spec.ts new file mode 100644 index 0000000..686f34c --- /dev/null +++ b/fe/src/components/valerie/VModal.spec.ts @@ -0,0 +1,283 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import VModal from './VModal.vue'; +import VIcon from './VIcon.vue'; // Used by VModal for close button +import { nextTick } from 'vue'; + +// Mock VIcon if its rendering is complex or not relevant to VModal's logic +vi.mock('./VIcon.vue', () => ({ + name: 'VIcon', + props: ['name'], + template: '', +})); + +// Helper to ensure Teleport content is rendered for testing +const getTeleportedModalContainer = (wrapper: any) => { + // Find the teleport target (usually body, but in test env it might be different or need setup) + // For JSDOM, teleported content is typically appended to document.body. + // We need to find the .modal-backdrop in the document.body. + const backdrop = document.querySelector('.modal-backdrop'); + if (!backdrop) return null; + + // Create a new wrapper around the teleported content for easier testing + // This is a bit of a hack, usually test-utils has better ways for Teleport. + // With Vue Test Utils v2, content inside is rendered and findable from the main wrapper + // if the target exists. Let's try finding directly from wrapper first. + const container = wrapper.find('.modal-container'); + if (container.exists()) return container; + + // Fallback if not found directly (e.g. if Teleport is to a detached element in test) + // This part might not be needed with modern test-utils and proper Teleport handling. + // For now, assuming wrapper.find works across teleports if modelValue is true. + return null; +}; + + +describe('VModal.vue', () => { + // Ensure body class is cleaned up after each test + afterEach(() => { + document.body.classList.remove('modal-open'); + // Remove any modal backdrops created during tests + document.querySelectorAll('.modal-backdrop').forEach(el => el.remove()); + }); + + it('does not render when modelValue is false', () => { + const wrapper = mount(VModal, { props: { modelValue: false } }); + // Modal content is teleported, so check for its absence in document or via a direct find + expect(wrapper.find('.modal-backdrop').exists()).toBe(false); + expect(wrapper.find('.modal-container').exists()).toBe(false); + }); + + it('renders when modelValue is true', async () => { + const wrapper = mount(VModal, { + props: { modelValue: true, title: 'Test Modal' }, + // Attach to document.body to ensure Teleport target exists + attachTo: document.body + }); + await nextTick(); // Wait for Teleport and transition + expect(wrapper.find('.modal-backdrop').exists()).toBe(true); + expect(wrapper.find('.modal-container').exists()).toBe(true); + expect(wrapper.find('.modal-title').text()).toBe('Test Modal'); + }); + + it('emits update:modelValue(false) and close on close button click', async () => { + const wrapper = mount(VModal, { + props: { modelValue: true }, + attachTo: document.body + }); + await nextTick(); + const closeButton = wrapper.find('.close-button'); + expect(closeButton.exists()).toBe(true); + await closeButton.trigger('click'); + + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual([false]); + expect(wrapper.emitted()['close']).toBeTruthy(); + }); + + it('hides close button when hideCloseButton is true', async () => { + const wrapper = mount(VModal, { + props: { modelValue: true, hideCloseButton: true }, + attachTo: document.body + }); + await nextTick(); + expect(wrapper.find('.close-button').exists()).toBe(false); + }); + + it('does not close on backdrop click if persistent is true', async () => { + const wrapper = mount(VModal, { + props: { modelValue: true, persistent: true }, + attachTo: document.body + }); + await nextTick(); + await wrapper.find('.modal-backdrop').trigger('click'); + expect(wrapper.emitted()['update:modelValue']).toBeUndefined(); + }); + + it('closes on backdrop click if persistent is false (default)', async () => { + const wrapper = mount(VModal, { + props: { modelValue: true }, // persistent is false by default + attachTo: document.body + }); + await nextTick(); + await wrapper.find('.modal-backdrop').trigger('click'); + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual([false]); + }); + + it('closes on Escape key press', async () => { + const wrapper = mount(VModal, { + props: { modelValue: true }, + attachTo: document.body // Necessary for document event listeners + }); + await nextTick(); // Modal is open, listener is attached + + // Simulate Escape key press on the document + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(escapeEvent); + await nextTick(); + + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual([false]); + }); + + it('does not close on Escape key if not open', async () => { + mount(VModal, { + props: { modelValue: false }, // Modal is not open initially + attachTo: document.body + }); + await nextTick(); + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(escapeEvent); + await nextTick(); + // No emissions expected as the listener shouldn't be active or modal shouldn't react + // This test is tricky as it tests absence of listener logic when closed. + // Relies on the fact that if it emitted, the test above would fail. + // No direct way to check emissions if component logic prevents it. + // We can assume if the 'closes on Escape key press' test is robust, this is covered. + }); + + + it('renders title from prop', async () => { + const titleText = 'My Modal Title'; + const wrapper = mount(VModal, { + props: { modelValue: true, title: titleText }, + attachTo: document.body + }); + await nextTick(); + expect(wrapper.find('.modal-title').text()).toBe(titleText); + }); + + it('renders header slot content', async () => { + const headerSlotContent = '
      Custom Header
      '; + const wrapper = mount(VModal, { + props: { modelValue: true }, + slots: { header: headerSlotContent }, + attachTo: document.body + }); + await nextTick(); + expect(wrapper.find('.custom-header').exists()).toBe(true); + expect(wrapper.find('.modal-title').exists()).toBe(false); // Default title should not render + expect(wrapper.find('.close-button').exists()).toBe(false); // Default close button also part of slot override + }); + + it('renders default (body) slot content', async () => { + const bodyContent = '

      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 = { + title: 'Valerie/VModal', + component: VModal, + tags: ['autodocs'], + argTypes: { + modelValue: { control: 'boolean', description: 'Controls modal visibility (v-model).' }, + title: { control: 'text' }, + hideCloseButton: { control: 'boolean' }, + persistent: { control: 'boolean' }, + size: { control: 'select', options: ['sm', 'md', 'lg'] }, + idBase: { control: 'text' }, + // Events + 'update:modelValue': { action: 'update:modelValue', table: { disable: true } }, + close: { action: 'close' }, + opened: { action: 'opened' }, + closed: { action: 'closed' }, + // Slots + header: { table: { disable: true } }, + default: { table: { disable: true } }, // Body slot + footer: { table: { disable: true } }, + }, + parameters: { + docs: { + description: { + component: 'A modal dialog component that teleports to the body. Supports v-model for visibility, custom content via slots, and various interaction options.', + }, + }, + // To better demonstrate modals, you might want a dark background for stories if not default + // backgrounds: { default: 'dark' }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Template for managing modal visibility in stories +const ModalInteractionTemplate: Story = { + render: (args) => ({ + components: { VModal, VButton, VInput, VFormField }, + setup() { + // Use a local ref for modelValue to simulate v-model behavior within the story + // This allows Storybook controls to set the initial 'modelValue' arg, + // and then the component and story can interact with this local state. + const isModalOpen = ref(args.modelValue); + + // Watch for changes from Storybook controls to update local state + watch(() => args.modelValue, (newVal) => { + isModalOpen.value = newVal; + }); + + // Function to update Storybook arg when local state changes (simulates emit) + const onUpdateModelValue = (val: boolean) => { + isModalOpen.value = val; + // args.modelValue = val; // This would update the control, but can cause loops if not careful + // Storybook's action logger for 'update:modelValue' will show this. + }; + + return { ...args, isModalOpen, onUpdateModelValue }; // Spread args to pass all other props + }, + // Base template structure, specific content will be overridden by each story + template: ` +
      + Open Modal + + + + + + + +
      + `, + }), +}; + +export const Basic: Story = { + ...ModalInteractionTemplate, + args: { + modelValue: false, // Initial state for Storybook control + title: 'Basic Modal Title', + bodyContent: 'This is the main content of the modal. You can put any HTML or Vue components here.', + size: 'md', + }, +}; + +export const WithCustomHeader: Story = { + ...ModalInteractionTemplate, + args: { + ...Basic.args, + title: 'This title is overridden by slot', + customHeaderSlot: true, // Custom arg for story to toggle header slot template + }, +}; + +export const Persistent: Story = { + ...ModalInteractionTemplate, + args: { + ...Basic.args, + title: 'Persistent Modal', + bodyContent: 'This modal will not close when clicking the backdrop. Use the "Close" button or Escape key.', + persistent: true, + }, +}; + +export const NoCloseButton: Story = { + ...ModalInteractionTemplate, + args: { + ...Basic.args, + title: 'No "X" Button', + bodyContent: 'The default header close button (X) is hidden. You must provide other means to close it (e.g., footer buttons, Esc key).', + hideCloseButton: true, + }, +}; + +export const SmallSize: Story = { + ...ModalInteractionTemplate, + args: { + ...Basic.args, + title: 'Small Modal (sm)', + size: 'sm', + bodyContent: 'This modal uses the "sm" size preset for a smaller width.', + }, +}; + +export const LargeSize: Story = { + ...ModalInteractionTemplate, + args: { + ...Basic.args, + title: 'Large Modal (lg)', + size: 'lg', + bodyContent: 'This modal uses the "lg" size preset for a larger width. Useful for forms or more content.', + }, +}; + +export const WithFormContent: Story = { + ...ModalInteractionTemplate, + render: (args) => ({ // Override render for specific slot content + components: { VModal, VButton, VInput, VFormField }, + setup() { + const isModalOpen = ref(args.modelValue); + watch(() => args.modelValue, (newVal) => { isModalOpen.value = newVal; }); + const onUpdateModelValue = (val: boolean) => { isModalOpen.value = val; }; + const username = ref(''); + const password = ref(''); + return { ...args, isModalOpen, onUpdateModelValue, username, password }; + }, + template: ` +
      + Open Form Modal + + + + + + + + + + +
      + `, + }), + args: { + ...Basic.args, + title: 'Login Form', + showFooter: true, // Ensure default footer with submit/cancel is shown by template logic + }, +}; + + +export const ConfirmOnClose: Story = { + render: (args) => ({ + components: { VModal, VButton, VInput }, + setup() { + const isModalOpen = ref(args.modelValue); + const textInput = ref("Some unsaved data..."); + const hasUnsavedChanges = computed(() => textInput.value !== ""); + + watch(() => args.modelValue, (newVal) => { isModalOpen.value = newVal; }); + + const requestClose = () => { + if (hasUnsavedChanges.value) { + if (confirm("You have unsaved changes. Are you sure you want to close?")) { + isModalOpen.value = false; + // args.modelValue = false; // Update arg + } + } else { + isModalOpen.value = false; + // args.modelValue = false; // Update arg + } + }; + + // This simulates the @update:modelValue from VModal, + // but intercepts it for confirmation logic. + const handleModalUpdate = (value: boolean) => { + if (value === false) { // Modal is trying to close + requestClose(); + } else { + isModalOpen.value = true; + // args.modelValue = true; + } + }; + + return { ...args, isModalOpen, textInput, handleModalUpdate, requestClose }; + }, + template: ` +
      + Open Modal with Confirmation + +

      Try to close this modal with text in the input field.

      + +

      No unsaved changes. Modal will close normally.

      +

      Unsaved changes detected!

      + + +
      +
      + `, + }), + args: { + ...Basic.args, + title: 'Confirm Close Modal', + bodyContent: '', // Content is in the template for this story + // persistent: true, // Good for confirm on close so backdrop click doesn't bypass confirm + // hideCloseButton: true, // Also good for confirm on close + }, +}; diff --git a/fe/src/components/valerie/VModal.vue b/fe/src/components/valerie/VModal.vue new file mode 100644 index 0000000..4d2568b --- /dev/null +++ b/fe/src/components/valerie/VModal.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/fe/src/components/valerie/VProgressBar.spec.ts b/fe/src/components/valerie/VProgressBar.spec.ts new file mode 100644 index 0000000..b89ea0c --- /dev/null +++ b/fe/src/components/valerie/VProgressBar.spec.ts @@ -0,0 +1,93 @@ +import { mount } from '@vue/test-utils'; +import VProgressBar from './VProgressBar.vue'; +import { describe, it, expect } from 'vitest'; + +describe('VProgressBar.vue', () => { + it('calculates percentage and sets width style correctly', () => { + const wrapper = mount(VProgressBar, { props: { value: 50, max: 100 } }); + const bar = wrapper.find('.progress-bar'); + expect(bar.attributes('style')).toContain('width: 50%;'); + }); + + it('calculates percentage correctly with different max values', () => { + const wrapper = mount(VProgressBar, { props: { value: 10, max: 20 } }); // 50% + expect(wrapper.find('.progress-bar').attributes('style')).toContain('width: 50%;'); + }); + + it('caps percentage at 100% if value exceeds max', () => { + const wrapper = mount(VProgressBar, { props: { value: 150, max: 100 } }); + expect(wrapper.find('.progress-bar').attributes('style')).toContain('width: 100%;'); + }); + + it('caps percentage at 0% if value is negative', () => { + const wrapper = mount(VProgressBar, { props: { value: -50, max: 100 } }); + expect(wrapper.find('.progress-bar').attributes('style')).toContain('width: 0%;'); + }); + + it('handles max value of 0 or less by setting width to 0%', () => { + const wrapperZeroMax = mount(VProgressBar, { props: { value: 50, max: 0 } }); + expect(wrapperZeroMax.find('.progress-bar').attributes('style')).toContain('width: 0%;'); + + const wrapperNegativeMax = mount(VProgressBar, { props: { value: 50, max: -10 } }); + expect(wrapperNegativeMax.find('.progress-bar').attributes('style')).toContain('width: 0%;'); + }); + + + it('shows progress text by default', () => { + const wrapper = mount(VProgressBar, { props: { value: 30 } }); + expect(wrapper.find('.progress-text').exists()).toBe(true); + expect(wrapper.find('.progress-text').text()).toBe('30%'); + }); + + it('hides progress text when showText is false', () => { + const wrapper = mount(VProgressBar, { props: { value: 30, showText: false } }); + expect(wrapper.find('.progress-text').exists()).toBe(false); + }); + + it('displays custom valueText when provided', () => { + const customText = 'Step 1 of 3'; + const wrapper = mount(VProgressBar, { + props: { value: 33, valueText: customText }, + }); + expect(wrapper.find('.progress-text').text()).toBe(customText); + }); + + it('displays percentage with zero decimal places by default', () => { + const wrapper = mount(VProgressBar, { props: { value: 33.333, max: 100 } }); + expect(wrapper.find('.progress-text').text()).toBe('33%'); // toFixed(0) + }); + + it('applies "striped" class by default', () => { + const wrapper = mount(VProgressBar, { props: { value: 50 } }); + expect(wrapper.find('.progress-bar').classes()).toContain('striped'); + }); + + it('does not apply "striped" class when striped prop is false', () => { + const wrapper = mount(VProgressBar, { props: { value: 50, striped: false } }); + expect(wrapper.find('.progress-bar').classes()).not.toContain('striped'); + }); + + it('sets ARIA attributes correctly', () => { + const labelText = 'Upload progress'; + const wrapper = mount(VProgressBar, { + props: { value: 60, max: 100, label: labelText }, + }); + const container = wrapper.find('.progress-container'); + expect(container.attributes('role')).toBe('progressbar'); + expect(container.attributes('aria-valuenow')).toBe('60'); + expect(container.attributes('aria-valuemin')).toBe('0'); + expect(container.attributes('aria-valuemax')).toBe('100'); + expect(container.attributes('aria-label')).toBe(labelText); + }); + + it('sets default ARIA label if label prop is not provided', () => { + const wrapper = mount(VProgressBar, { props: { value: 10 } }); + expect(wrapper.find('.progress-container').attributes('aria-label')).toBe('Progress indicator'); + }); + + it('has .progress-container and .progress-bar classes', () => { + const wrapper = mount(VProgressBar, { props: { value: 10 } }); + expect(wrapper.find('.progress-container').exists()).toBe(true); + expect(wrapper.find('.progress-bar').exists()).toBe(true); + }); +}); diff --git a/fe/src/components/valerie/VProgressBar.stories.ts b/fe/src/components/valerie/VProgressBar.stories.ts new file mode 100644 index 0000000..9d2dd62 --- /dev/null +++ b/fe/src/components/valerie/VProgressBar.stories.ts @@ -0,0 +1,166 @@ +import VProgressBar from './VProgressBar.vue'; +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref } +from 'vue'; // For interactive stories if needed + +const meta: Meta = { + title: 'Valerie/VProgressBar', + component: VProgressBar, + tags: ['autodocs'], + argTypes: { + value: { control: { type: 'range', min: 0, max: 100, step: 1 }, description: 'Current progress value.' }, // Assuming max=100 for control simplicity + max: { control: 'number', description: 'Maximum progress value.' }, + showText: { control: 'boolean' }, + striped: { control: 'boolean' }, + label: { control: 'text', description: 'Accessible label for the progress bar.' }, + valueText: { control: 'text', description: 'Custom text to display instead of percentage.' }, + }, + parameters: { + docs: { + description: { + component: 'A progress bar component to display the current completion status of a task. Supports customizable text, stripes, and ARIA attributes.', + }, + }, + }, + // Decorator to provide a container for better visualization if needed + // decorators: [() => ({ template: '
      ' })], +}; + +export default meta; +type Story = StoryObj; + +export const DefaultAt25Percent: Story = { + args: { + value: 25, + max: 100, + label: 'Task progress', + }, +}; + +export const At0Percent: Story = { + args: { + ...DefaultAt25Percent.args, + value: 0, + }, +}; + +export const At50Percent: Story = { + args: { + ...DefaultAt25Percent.args, + value: 50, + }, +}; + +export const At75Percent: Story = { + args: { + ...DefaultAt25Percent.args, + value: 75, + }, +}; + +export const At100Percent: Story = { + args: { + ...DefaultAt25Percent.args, + value: 100, + }, +}; + +export const NoText: Story = { + args: { + ...DefaultAt25Percent.args, + value: 60, + showText: false, + label: 'Loading data (visual only)', + }, +}; + +export const NoStripes: Story = { + args: { + ...DefaultAt25Percent.args, + value: 70, + striped: false, + label: 'Download status (no stripes)', + }, +}; + +export const CustomMaxValue: Story = { + args: { + value: 10, + max: 20, // Max is 20, so 10 is 50% + label: 'Steps completed', + }, +}; + +export const WithCustomValueText: Story = { + args: { + value: 3, + max: 5, + valueText: 'Step 3 of 5', + label: 'Onboarding process', + }, +}; + +export const ValueOverMax: Story = { + args: { + ...DefaultAt25Percent.args, + value: 150, // Should be capped at 100% + label: 'Overloaded progress', + }, +}; + +export const NegativeValue: Story = { + args: { + ...DefaultAt25Percent.args, + value: -20, // Should be capped at 0% + label: 'Invalid progress', + }, +}; + +// Interactive story example (optional, if manual controls aren't enough) +export const InteractiveUpdate: Story = { + render: (args) => ({ + components: { VProgressBar }, + setup() { + const currentValue = ref(args.value || 10); + const intervalId = ref(null); + + const startProgress = () => { + if (intervalId.value) clearInterval(intervalId.value); + currentValue.value = 0; + intervalId.value = setInterval(() => { + currentValue.value += 10; + if (currentValue.value >= (args.max || 100)) { + currentValue.value = args.max || 100; + if (intervalId.value) clearInterval(intervalId.value); + } + }, 500); + }; + + onBeforeUnmount(() => { + if (intervalId.value) clearInterval(intervalId.value); + }); + + return { ...args, currentValue, startProgress }; + }, + template: ` +
      + + +
      + `, + }), + args: { + value: 10, // Initial value for the ref + max: 100, + showText: true, + striped: true, + label: 'Dynamic Progress', + }, +}; diff --git a/fe/src/components/valerie/VProgressBar.vue b/fe/src/components/valerie/VProgressBar.vue new file mode 100644 index 0000000..7821c08 --- /dev/null +++ b/fe/src/components/valerie/VProgressBar.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/fe/src/components/valerie/VRadio.spec.ts b/fe/src/components/valerie/VRadio.spec.ts new file mode 100644 index 0000000..b2668e9 --- /dev/null +++ b/fe/src/components/valerie/VRadio.spec.ts @@ -0,0 +1,129 @@ +import { mount } from '@vue/test-utils'; +import VRadio from './VRadio.vue'; +import { describe, it, expect } from 'vitest'; + +describe('VRadio.vue', () => { + it('binds modelValue, reflects checked state, and emits update:modelValue', async () => { + const wrapper = mount(VRadio, { + props: { + modelValue: 'initialGroupValue', // This radio is not selected initially + value: 'thisRadioValue', + name: 'testGroup', + id: 'test-radio1', + }, + }); + const inputElement = wrapper.find('input[type="radio"]'); + + // Initial state (not checked) + expect(inputElement.element.checked).toBe(false); + + // Simulate parent selecting this radio button + await wrapper.setProps({ modelValue: 'thisRadioValue' }); + expect(inputElement.element.checked).toBe(true); + + // Simulate user clicking this radio (which is already selected by parent) + // No change event if already checked and clicked again (browser behavior) + // So, let's test selection from an unselected state by changing modelValue first + await wrapper.setProps({ modelValue: 'anotherValue' }); + expect(inputElement.element.checked).toBe(false); // Ensure it's unselected + + // Simulate user clicking this radio button to select it + // Note: setChecked() on a radio in a group might not trigger change as expected in JSDOM + // A direct .trigger('change') is more reliable for unit testing radio logic. + // Or, if the radio is part of a group, only one can be checked. + // The component's logic is that if it's clicked, it emits its value. + + // Manually trigger change as if user clicked THIS radio specifically + await inputElement.trigger('change'); + + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + // The last emission (or first if only one) should be its own value + const emissions = wrapper.emitted()['update:modelValue']; + expect(emissions[emissions.length -1]).toEqual(['thisRadioValue']); + + // After emitting, if the parent updates modelValue, it should reflect + await wrapper.setProps({ modelValue: 'thisRadioValue' }); + expect(inputElement.element.checked).toBe(true); + }); + + it('is checked when modelValue matches its value', () => { + const wrapper = mount(VRadio, { + props: { modelValue: 'selectedVal', value: 'selectedVal', name: 'group' }, + }); + expect(wrapper.find('input[type="radio"]').element.checked).toBe(true); + }); + + it('is not checked when modelValue does not match its value', () => { + const wrapper = mount(VRadio, { + props: { modelValue: 'otherVal', value: 'thisVal', name: 'group' }, + }); + expect(wrapper.find('input[type="radio"]').element.checked).toBe(false); + }); + + it('renders label when label prop is provided', () => { + const labelText = 'Select this radio'; + const wrapper = mount(VRadio, { + props: { modelValue: '', value: 'any', name: 'group', label: labelText }, + }); + const labelElement = wrapper.find('.radio-text-label'); + expect(labelElement.exists()).toBe(true); + expect(labelElement.text()).toBe(labelText); + }); + + it('is disabled when disabled prop is true', () => { + const wrapper = mount(VRadio, { + props: { modelValue: '', value: 'any', name: 'group', disabled: true }, + }); + expect(wrapper.find('input[type="radio"]').attributes('disabled')).toBeDefined(); + expect(wrapper.find('.radio-label').classes()).toContain('disabled'); + }); + + it('applies name and value attributes correctly', () => { + const nameVal = 'contactPreference'; + const valueVal = 'email'; + const wrapper = mount(VRadio, { + props: { modelValue: '', value: valueVal, name: nameVal }, + }); + const input = wrapper.find('input[type="radio"]'); + expect(input.attributes('name')).toBe(nameVal); + expect(input.attributes('value')).toBe(valueVal); + }); + + it('passes id prop to input and label for attribute if provided', () => { + const radioId = 'my-custom-radio-id'; + const wrapper = mount(VRadio, { + props: { modelValue: '', value: 'any', name: 'group', id: radioId }, + }); + expect(wrapper.find('input[type="radio"]').attributes('id')).toBe(radioId); + expect(wrapper.find('.radio-label').attributes('for')).toBe(radioId); + }); + + it('generates an effectiveId if id prop is not provided', () => { + const wrapper = mount(VRadio, { + props: { modelValue: '', value: 'valX', name: 'groupY' }, + }); + const expectedId = 'vradio-groupY-valX'; + expect(wrapper.find('input[type="radio"]').attributes('id')).toBe(expectedId); + expect(wrapper.find('.radio-label').attributes('for')).toBe(expectedId); + }); + + + it('contains a .checkmark.radio-mark span', () => { + const wrapper = mount(VRadio, { props: { modelValue: '', value: 'any', name: 'group' } }); + expect(wrapper.find('.checkmark.radio-mark').exists()).toBe(true); + }); + + it('adds "checked" class to label when radio is checked', () => { + const wrapper = mount(VRadio, { + props: { modelValue: 'thisValue', value: 'thisValue', name: 'group' }, + }); + expect(wrapper.find('.radio-label').classes()).toContain('checked'); + }); + + it('does not add "checked" class to label when radio is not checked', () => { + const wrapper = mount(VRadio, { + props: { modelValue: 'otherValue', value: 'thisValue', name: 'group' }, + }); + expect(wrapper.find('.radio-label').classes()).not.toContain('checked'); + }); +}); diff --git a/fe/src/components/valerie/VRadio.stories.ts b/fe/src/components/valerie/VRadio.stories.ts new file mode 100644 index 0000000..1938b25 --- /dev/null +++ b/fe/src/components/valerie/VRadio.stories.ts @@ -0,0 +1,176 @@ +import VRadio from './VRadio.vue'; +import VFormField from './VFormField.vue'; // For context if showing errors related to a radio group +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref, watch } from 'vue'; + +const meta: Meta = { + title: 'Valerie/VRadio', + component: VRadio, + tags: ['autodocs'], + argTypes: { + modelValue: { control: 'text', description: 'Current selected value in the radio group (v-model).' }, + value: { control: 'text', description: 'The unique value this radio button represents.' }, + label: { control: 'text' }, + disabled: { control: 'boolean' }, + id: { control: 'text' }, + name: { control: 'text', description: 'HTML `name` attribute for grouping.' }, + // 'update:modelValue': { action: 'updated' } + }, + parameters: { + docs: { + description: { + component: 'A custom radio button component. Group multiple VRadio components with the same `name` prop and bind them to the same `v-model` for a radio group.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Template for a single VRadio instance, primarily for showing individual states +const SingleRadioTemplate: Story = { + render: (args) => ({ + components: { VRadio }, + setup() { + const storyValue = ref(args.modelValue); + watch(() => args.modelValue, (newVal) => { + storyValue.value = newVal; + }); + const onChange = (newValue: string | number) => { + storyValue.value = newValue; + // args.modelValue = newValue; // Update Storybook arg + } + return { args, storyValue, onChange }; + }, + template: '', + }), +}; + + +// Story for a group of radio buttons +export const RadioGroup: Story = { + render: (args) => ({ + components: { VRadio }, + setup() { + const selectedValue = ref(args.groupModelValue || 'opt2'); // Default selected value for the group + // This simulates how a parent component would handle the v-model for the group + return { args, selectedValue }; + }, + template: ` +
      + +

      Selected: {{ selectedValue }}

      +
      + `, + }), + args: { + name: 'storyGroup', + groupModelValue: 'opt2', // Initial selected value for the group + options: [ + { value: 'opt1', label: 'Option 1' }, + { value: 'opt2', label: 'Option 2 (Default)' }, + { value: 'opt3', label: 'Option 3 (Longer Label)' }, + { value: 'opt4', label: 'Option 4 (Disabled)', disabled: true }, + { value: 5, label: 'Option 5 (Number value)'} + ], + }, + parameters: { + docs: { + description: { story: 'A group of `VRadio` components. They share the same `name` and `v-model` (here `selectedValue`).' }, + }, + }, +}; + +export const WithLabel: Story = { + ...SingleRadioTemplate, + args: { + id: 'labelledRadio', + name: 'single', + modelValue: 'myValue', // This radio is selected because modelValue === value + value: 'myValue', + label: 'Choose this option', + }, +}; + + +export const DisabledUnselected: Story = { + ...SingleRadioTemplate, + args: { + id: 'disabledUnselectedRadio', + name: 'singleDisabled', + modelValue: 'anotherValue', // This radio is not selected + value: 'thisValue', + label: 'Cannot select this', + disabled: true, + }, +}; + +export const DisabledSelected: Story = { + ...SingleRadioTemplate, + args: { + id: 'disabledSelectedRadio', + name: 'singleDisabled', + modelValue: 'thisValueSelected', // This radio IS selected + value: 'thisValueSelected', + label: 'Selected and disabled', + disabled: true, + }, +}; + + +// It's less common to use VRadio directly in VFormField for its label, +// but VFormField could provide an error message for a radio group. +export const GroupInFormFieldForError: Story = { + render: (args) => ({ + components: { VRadio, VFormField }, + setup() { + const selectedValue = ref(args.groupModelValue || null); + return { args, selectedValue }; + }, + template: ` + +
      + +
      +

      Selected: {{ selectedValue }}

      +
      + `, + }), + args: { + name: 'formFieldRadioGroup', + groupModelValue: null, // Start with no selection + options: [ + { value: 'ffOpt1', label: 'Option A' }, + { value: 'ffOpt2', label: 'Option B' }, + ], + formFieldArgs: { + labelId: 'radioGroupLabel', // An ID for the label if VFormField label is used as group label + label: 'Please make a selection:', + errorMessage: 'A selection is required for this group.', + } + }, + parameters: { + docs: { + description: { story: 'A radio group within `VFormField`. `VFormField` can provide a group label (via `aria-labelledby`) and display error messages related to the group.' }, + }, + }, +}; diff --git a/fe/src/components/valerie/VRadio.vue b/fe/src/components/valerie/VRadio.vue new file mode 100644 index 0000000..b87dd82 --- /dev/null +++ b/fe/src/components/valerie/VRadio.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/fe/src/components/valerie/VSelect.spec.ts b/fe/src/components/valerie/VSelect.spec.ts new file mode 100644 index 0000000..0dc9ea4 --- /dev/null +++ b/fe/src/components/valerie/VSelect.spec.ts @@ -0,0 +1,132 @@ +import { mount } from '@vue/test-utils'; +import VSelect from './VSelect.vue'; +import { describe, it, expect } from 'vitest'; + +const testOptions = [ + { value: 'val1', label: 'Label 1' }, + { value: 'val2', label: 'Label 2', disabled: true }, + { value: 3, label: 'Label 3 (number)' }, // Numeric value +]; + +describe('VSelect.vue', () => { + it('binds modelValue and emits update:modelValue correctly (v-model)', async () => { + const wrapper = mount(VSelect, { + props: { modelValue: 'val1', options: testOptions }, + }); + const selectElement = wrapper.find('select'); + + // Check initial value + expect(selectElement.element.value).toBe('val1'); + + // Simulate user changing selection + await selectElement.setValue('val2'); // This will select the option with value "val2" + + // Check emitted event + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['val2']); + + // Simulate parent v-model update + await wrapper.setProps({ modelValue: 'val1' }); + expect(selectElement.element.value).toBe('val1'); + }); + + it('correctly emits numeric value when a number option is selected', async () => { + const wrapper = mount(VSelect, { + props: { modelValue: '', options: testOptions, placeholder: 'Select...' }, + }); + const selectElement = wrapper.find('select'); + await selectElement.setValue('3'); // Value of 'Label 3 (number)' is 3 (a number) + expect(wrapper.emitted()['update:modelValue'][0]).toEqual([3]); // Should emit the number 3 + }); + + + it('renders options correctly with labels, values, and disabled states', () => { + const wrapper = mount(VSelect, { + props: { modelValue: '', options: testOptions }, + }); + const optionElements = wrapper.findAll('option'); + expect(optionElements.length).toBe(testOptions.length); + + testOptions.forEach((opt, index) => { + const optionElement = optionElements[index]; + expect(optionElement.attributes('value')).toBe(String(opt.value)); + expect(optionElement.text()).toBe(opt.label); + if (opt.disabled) { + expect(optionElement.attributes('disabled')).toBeDefined(); + } else { + expect(optionElement.attributes('disabled')).toBeUndefined(); + } + }); + }); + + it('renders a placeholder option when placeholder prop is provided', () => { + const placeholderText = 'Choose...'; + const wrapper = mount(VSelect, { + props: { modelValue: '', options: testOptions, placeholder: placeholderText }, + }); + const placeholderOption = wrapper.find('option[value=""]'); + expect(placeholderOption.exists()).toBe(true); + expect(placeholderOption.text()).toBe(placeholderText); + expect(placeholderOption.attributes('disabled')).toBeDefined(); + // Check if it's selected when modelValue is empty + expect(placeholderOption.element.selected).toBe(true); + }); + + it('placeholder is not selected if modelValue has a value', () => { + const placeholderText = 'Choose...'; + const wrapper = mount(VSelect, { + props: { modelValue: 'val1', options: testOptions, placeholder: placeholderText }, + }); + const placeholderOption = wrapper.find('option[value=""]'); + expect(placeholderOption.element.selected).toBe(false); + const selectedVal1 = wrapper.find('option[value="val1"]'); + expect(selectedVal1.element.selected).toBe(true); + }); + + + it('is disabled when disabled prop is true', () => { + const wrapper = mount(VSelect, { + props: { modelValue: '', options: testOptions, disabled: true }, + }); + expect(wrapper.find('select').attributes('disabled')).toBeDefined(); + }); + + it('is required when required prop is true', () => { + const wrapper = mount(VSelect, { + props: { modelValue: '', options: testOptions, required: true }, + }); + expect(wrapper.find('select').attributes('required')).toBeDefined(); + }); + + it('applies error class and aria-invalid when error prop is true', () => { + const wrapper = mount(VSelect, { + props: { modelValue: '', options: testOptions, error: true }, + }); + const select = wrapper.find('select'); + expect(select.classes()).toContain('form-input'); + expect(select.classes()).toContain('select'); + expect(select.classes()).toContain('error'); + expect(select.attributes('aria-invalid')).toBe('true'); + }); + + it('does not apply error class or aria-invalid by default', () => { + const wrapper = mount(VSelect, { props: { modelValue: '', options: testOptions } }); + const select = wrapper.find('select'); + expect(select.classes()).not.toContain('error'); + expect(select.attributes('aria-invalid')).toBeNull(); + }); + + it('passes id prop to the select element', () => { + const selectId = 'my-custom-select-id'; + const wrapper = mount(VSelect, { + props: { modelValue: '', options: testOptions, id: selectId }, + }); + expect(wrapper.find('select').attributes('id')).toBe(selectId); + }); + + it('has "select" and "form-input" classes', () => { + const wrapper = mount(VSelect, { props: { modelValue: '', options: testOptions } }); + expect(wrapper.find('select').classes()).toContain('select'); + expect(wrapper.find('select').classes()).toContain('form-input'); + }); +}); diff --git a/fe/src/components/valerie/VSelect.stories.ts b/fe/src/components/valerie/VSelect.stories.ts new file mode 100644 index 0000000..5e786cd --- /dev/null +++ b/fe/src/components/valerie/VSelect.stories.ts @@ -0,0 +1,197 @@ +import VSelect from './VSelect.vue'; +import VFormField from './VFormField.vue'; // To demonstrate usage with VFormField +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref } from 'vue'; // For v-model in stories + +const sampleOptions = [ + { value: '', label: 'Select an option (from options prop, if placeholder not used)' , disabled: true}, + { value: 'opt1', label: 'Option 1' }, + { value: 'opt2', label: 'Option 2 (Longer Text)' }, + { value: 'opt3', label: 'Option 3', disabled: true }, + { value: 4, label: 'Option 4 (Number Value)' }, // Example with number value + { value: 'opt5', label: 'Option 5' }, +]; + +const meta: Meta = { + title: 'Valerie/VSelect', + component: VSelect, + tags: ['autodocs'], + argTypes: { + modelValue: { control: 'select', options: ['', 'opt1', 'opt2', 4, 'opt5'], description: 'Bound value using v-model. Control shows possible values from sampleOptions.' }, + options: { control: 'object' }, + disabled: { control: 'boolean' }, + required: { control: 'boolean' }, + error: { control: 'boolean', description: 'Applies error styling.' }, + id: { control: 'text' }, + placeholder: { control: 'text' }, + // 'update:modelValue': { action: 'updated' } + }, + parameters: { + docs: { + description: { + component: 'A select component for choosing from a list of options, supporting v-model, states, and placeholder.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Template for v-model interaction in stories +const VModelTemplate: Story = { + render: (args) => ({ + components: { VSelect }, + setup() { + // Storybook's args are reactive. For v-model, ensure the control updates the arg. + // If direct v-model="args.modelValue" has issues, a local ref can be used. + const storyValue = ref(args.modelValue); // Initialize with current arg value + const onChange = (newValue: string | number) => { + storyValue.value = newValue; // Update local ref + // args.modelValue = newValue; // This would update the arg if mutable, SB controls should handle this + } + // Watch for external changes to modelValue from Storybook controls + watch(() => args.modelValue, (newVal) => { + storyValue.value = newVal; + }); + return { args, storyValue, onChange }; + }, + template: '', + }), +}; + +export const Basic: Story = { + ...VModelTemplate, + args: { + id: 'basicSelect', + options: sampleOptions.filter(opt => !opt.disabled || opt.value === ''), // Filter out pre-disabled for basic + modelValue: 'opt1', + }, +}; + +export const WithPlaceholder: Story = { + ...VModelTemplate, + args: { + id: 'placeholderSelect', + options: sampleOptions.filter(opt => opt.value !== ''), // Remove the empty value option from main list if placeholder is used + modelValue: '', // Placeholder should be selected + placeholder: 'Please choose an item...', + }, +}; + +export const Disabled: Story = { + ...VModelTemplate, + args: { + id: 'disabledSelect', + options: sampleOptions, + modelValue: 'opt2', + disabled: true, + }, +}; + +export const Required: Story = { + ...VModelTemplate, + args: { + id: 'requiredSelect', + options: sampleOptions.filter(opt => opt.value !== ''), + modelValue: '', // Start with nothing selected if required and placeholder exists + placeholder: 'You must select one', + required: true, + }, + parameters: { + docs: { + description: { story: 'The `required` attribute is set. If a placeholder is present and selected, form validation may fail as expected.' }, + }, + }, +}; + +export const ErrorState: Story = { + ...VModelTemplate, + args: { + id: 'errorSelect', + options: sampleOptions, + modelValue: 'opt1', + error: true, + }, +}; + +export const WithDisabledOptions: Story = { + ...VModelTemplate, + args: { + id: 'disabledOptionsSelect', + options: sampleOptions, // sampleOptions already includes a disabled option (opt3) + modelValue: 'opt1', + }, +}; + +export const NumberValueSelected: Story = { + ...VModelTemplate, + args: { + id: 'numberValueSelect', + options: sampleOptions, + modelValue: 4, // Corresponds to 'Option 4 (Number Value)' + }, +}; + + +// Story demonstrating VSelect used within VFormField +export const InFormField: Story = { + render: (args) => ({ + components: { VSelect, VFormField }, + setup() { + const storyValue = ref(args.selectArgs.modelValue); + watch(() => args.selectArgs.modelValue, (newVal) => { + storyValue.value = newVal; + }); + const onChange = (newValue: string | number) => { + storyValue.value = newValue; + } + return { args, storyValue, onChange }; + }, + template: ` + + + + `, + }), + args: { + formFieldArgs: { + label: 'Choose Category', + errorMessage: '', + }, + selectArgs: { + id: 'categorySelect', + options: sampleOptions.filter(opt => opt.value !== ''), + modelValue: '', + placeholder: 'Select a category...', + error: false, + }, + }, + parameters: { + docs: { + description: { story: '`VSelect` used inside a `VFormField`. The `id` on `VSelect` should match `forId` on `VFormField`.' }, + }, + }, +}; + +export const InFormFieldWithError: Story = { + ...InFormField, // Inherit render function + args: { + formFieldArgs: { + label: 'Select Priority', + errorMessage: 'A priority must be selected.', + }, + selectArgs: { + id: 'prioritySelectError', + options: sampleOptions.filter(opt => opt.value !== ''), + modelValue: '', // Nothing selected, causing error + placeholder: 'Choose priority...', + error: true, // Set VSelect's error state + required: true, + }, + }, +}; diff --git a/fe/src/components/valerie/VSelect.vue b/fe/src/components/valerie/VSelect.vue new file mode 100644 index 0000000..8aa65c4 --- /dev/null +++ b/fe/src/components/valerie/VSelect.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/fe/src/components/valerie/VTextarea.spec.ts b/fe/src/components/valerie/VTextarea.spec.ts new file mode 100644 index 0000000..5feb35b --- /dev/null +++ b/fe/src/components/valerie/VTextarea.spec.ts @@ -0,0 +1,117 @@ +import { mount } from '@vue/test-utils'; +import VTextarea from './VTextarea.vue'; +import { describe, it, expect } from 'vitest'; + +describe('VTextarea.vue', () => { + it('binds modelValue and emits update:modelValue correctly (v-model)', async () => { + const wrapper = mount(VTextarea, { + props: { modelValue: 'initial content' }, + }); + const textareaElement = wrapper.find('textarea'); + + // Check initial value + expect(textareaElement.element.value).toBe('initial content'); + + // Simulate user input + await textareaElement.setValue('new content'); + + // Check emitted event + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['new content']); + + // Check that prop update (simulating parent v-model update) changes the value + await wrapper.setProps({ modelValue: 'updated from parent' }); + expect(textareaElement.element.value).toBe('updated from parent'); + }); + + it('applies placeholder when provided', () => { + const placeholderText = 'Enter details...'; + const wrapper = mount(VTextarea, { + props: { modelValue: '', placeholder: placeholderText }, + }); + expect(wrapper.find('textarea').attributes('placeholder')).toBe(placeholderText); + }); + + it('is disabled when disabled prop is true', () => { + const wrapper = mount(VTextarea, { + props: { modelValue: '', disabled: true }, + }); + expect(wrapper.find('textarea').attributes('disabled')).toBeDefined(); + }); + + it('is not disabled by default', () => { + const wrapper = mount(VTextarea, { props: { modelValue: '' } }); + expect(wrapper.find('textarea').attributes('disabled')).toBeUndefined(); + }); + + it('is required when required prop is true', () => { + const wrapper = mount(VTextarea, { + props: { modelValue: '', required: true }, + }); + expect(wrapper.find('textarea').attributes('required')).toBeDefined(); + }); + + it('is not required by default', () => { + const wrapper = mount(VTextarea, { props: { modelValue: '' } }); + expect(wrapper.find('textarea').attributes('required')).toBeUndefined(); + }); + + it('sets the rows attribute correctly', () => { + const wrapper = mount(VTextarea, { + props: { modelValue: '', rows: 5 }, + }); + expect(wrapper.find('textarea').attributes('rows')).toBe('5'); + }); + + it('defaults rows to 3 if not provided', () => { + const wrapper = mount(VTextarea, { props: { modelValue: '' } }); + expect(wrapper.find('textarea').attributes('rows')).toBe('3'); + }); + + it('applies error class and aria-invalid when error prop is true', () => { + const wrapper = mount(VTextarea, { + props: { modelValue: '', error: true }, + }); + const textarea = wrapper.find('textarea'); + expect(textarea.classes()).toContain('form-input'); + expect(textarea.classes()).toContain('textarea'); // Specific class + expect(textarea.classes()).toContain('error'); + expect(textarea.attributes('aria-invalid')).toBe('true'); + }); + + it('does not apply error class or aria-invalid by default or when error is false', () => { + const wrapperDefault = mount(VTextarea, { props: { modelValue: '' } }); + const textareaDefault = wrapperDefault.find('textarea'); + expect(textareaDefault.classes()).toContain('form-input'); + expect(textareaDefault.classes()).toContain('textarea'); + expect(textareaDefault.classes()).not.toContain('error'); + expect(textareaDefault.attributes('aria-invalid')).toBeNull(); + + + const wrapperFalse = mount(VTextarea, { + props: { modelValue: '', error: false }, + }); + const textareaFalse = wrapperFalse.find('textarea'); + expect(textareaFalse.classes()).not.toContain('error'); + expect(textareaFalse.attributes('aria-invalid')).toBeNull(); + }); + + it('passes id prop to the textarea element', () => { + const textareaId = 'my-custom-textarea-id'; + const wrapper = mount(VTextarea, { + props: { modelValue: '', id: textareaId }, + }); + expect(wrapper.find('textarea').attributes('id')).toBe(textareaId); + }); + + it('does not have an id attribute if id prop is not provided', () => { + const wrapper = mount(VTextarea, { props: { modelValue: '' } }); + expect(wrapper.find('textarea').attributes('id')).toBeUndefined(); + }); + + it('includes "textarea" class in addition to "form-input"', () => { + const wrapper = mount(VTextarea, { props: { modelValue: '' } }); + expect(wrapper.find('textarea').classes()).toContain('textarea'); + expect(wrapper.find('textarea').classes()).toContain('form-input'); + }); +}); diff --git a/fe/src/components/valerie/VTextarea.stories.ts b/fe/src/components/valerie/VTextarea.stories.ts new file mode 100644 index 0000000..81be555 --- /dev/null +++ b/fe/src/components/valerie/VTextarea.stories.ts @@ -0,0 +1,173 @@ +import VTextarea from './VTextarea.vue'; +import VFormField from './VFormField.vue'; // To demonstrate usage with VFormField +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref } from 'vue'; // For v-model in stories + +const meta: Meta = { + title: 'Valerie/VTextarea', + component: VTextarea, + tags: ['autodocs'], + argTypes: { + modelValue: { control: 'text', description: 'Bound value using v-model.' }, + placeholder: { control: 'text' }, + disabled: { control: 'boolean' }, + required: { control: 'boolean' }, + rows: { control: 'number' }, + error: { control: 'boolean', description: 'Applies error styling.' }, + id: { control: 'text' }, + // 'update:modelValue': { action: 'updated' } + }, + parameters: { + docs: { + description: { + component: 'A textarea component for multi-line text input, supporting v-model, states, and customizable rows.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Template for v-model interaction in stories (similar to VInput) +const VModelTemplate: Story = { + render: (args) => ({ + components: { VTextarea }, + setup() { + const storyValue = ref(args.modelValue || ''); + const onInput = (newValue: string) => { + storyValue.value = newValue; + // context.emit('update:modelValue', newValue); // For SB actions + } + return { args, storyValue, onInput }; + }, + template: '', + }), +}; + +export const Basic: Story = { + ...VModelTemplate, + args: { + id: 'basicTextarea', + modelValue: 'This is some multi-line text.\nIt spans across multiple lines.', + }, +}; + +export const WithPlaceholder: Story = { + ...VModelTemplate, + args: { + id: 'placeholderTextarea', + placeholder: 'Enter your comments here...', + modelValue: '', + }, +}; + +export const Disabled: Story = { + ...VModelTemplate, + args: { + id: 'disabledTextarea', + modelValue: 'This content cannot be changed.', + disabled: true, + }, +}; + +export const Required: Story = { + ...VModelTemplate, + args: { + id: 'requiredTextarea', + modelValue: '', + required: true, + placeholder: 'This field is required', + }, + parameters: { + docs: { + description: { story: 'The `required` attribute is set. Form submission behavior depends on the browser and form context.' }, + }, + }, +}; + +export const ErrorState: Story = { + ...VModelTemplate, + args: { + id: 'errorTextarea', + modelValue: 'This text has some issues.', + error: true, + }, +}; + +export const CustomRows: Story = { + ...VModelTemplate, + args: { + id: 'customRowsTextarea', + modelValue: 'This textarea has more rows.\nAllowing for more visible text.\nWithout scrolling initially.', + rows: 5, + }, +}; + +export const FewerRows: Story = { + ...VModelTemplate, + args: { + id: 'fewerRowsTextarea', + modelValue: 'Only two rows here.', + rows: 2, + }, +}; + + +// Story demonstrating VTextarea used within VFormField +export const InFormField: Story = { + render: (args) => ({ + components: { VTextarea, VFormField }, + setup() { + const storyValue = ref(args.textareaArgs.modelValue || ''); + const onInput = (newValue: string) => { + storyValue.value = newValue; + } + return { args, storyValue, onInput }; + }, + template: ` + + + + `, + }), + args: { + formFieldArgs: { + label: 'Your Feedback', + errorMessage: '', + }, + textareaArgs: { + id: 'feedbackField', + modelValue: '', + placeholder: 'Please provide your detailed feedback...', + rows: 4, + error: false, + }, + }, + parameters: { + docs: { + description: { story: '`VTextarea` used inside a `VFormField`. The `id` on `VTextarea` should match `forId` on `VFormField`.' }, + }, + }, +}; + +export const InFormFieldWithError: Story = { + ...InFormField, // Inherit render function + args: { + formFieldArgs: { + label: 'Description', + errorMessage: 'The description is too short.', + }, + textareaArgs: { + id: 'descriptionFieldError', + modelValue: 'Too brief.', + placeholder: 'Provide a detailed description', + rows: 3, + error: true, // Set VTextarea's error state + }, + }, +}; diff --git a/fe/src/components/valerie/VTextarea.vue b/fe/src/components/valerie/VTextarea.vue new file mode 100644 index 0000000..daa795f --- /dev/null +++ b/fe/src/components/valerie/VTextarea.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/fe/src/components/valerie/VToggleSwitch.spec.ts b/fe/src/components/valerie/VToggleSwitch.spec.ts new file mode 100644 index 0000000..97934c1 --- /dev/null +++ b/fe/src/components/valerie/VToggleSwitch.spec.ts @@ -0,0 +1,109 @@ +import { mount } from '@vue/test-utils'; +import VToggleSwitch from './VToggleSwitch.vue'; +import { describe, it, expect } from 'vitest'; + +describe('VToggleSwitch.vue', () => { + it('binds modelValue and emits update:modelValue on change', async () => { + const wrapper = mount(VToggleSwitch, { + props: { modelValue: false, id: 'test-switch' }, // id is required due to default prop generation if not passed + }); + const input = wrapper.find('input[type="checkbox"]'); + + // Initial state + expect(input.element.checked).toBe(false); + + // Simulate change by setting checked state (how user interacts) + await input.setChecked(true); + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual([true]); + + // Simulate parent updating modelValue + await wrapper.setProps({ modelValue: true }); + expect(input.element.checked).toBe(true); + + await input.setChecked(false); + expect(wrapper.emitted()['update:modelValue'][1]).toEqual([false]); + }); + + it('applies disabled state to input and container class', () => { + const wrapper = mount(VToggleSwitch, { + props: { modelValue: false, disabled: true, id: 'disabled-switch' }, + }); + expect(wrapper.find('input[type="checkbox"]').attributes('disabled')).toBeDefined(); + expect(wrapper.find('.switch-container').classes()).toContain('disabled'); + }); + + it('is not disabled by default', () => { + const wrapper = mount(VToggleSwitch, { props: { modelValue: false, id: 'enabled-switch' } }); + expect(wrapper.find('input[type="checkbox"]').attributes('disabled')).toBeUndefined(); + expect(wrapper.find('.switch-container').classes()).not.toContain('disabled'); + }); + + it('applies provided id or generates one automatically', () => { + const providedId = 'my-custom-id'; + const wrapperWithId = mount(VToggleSwitch, { + props: { modelValue: false, id: providedId }, + }); + const inputWithId = wrapperWithId.find('input[type="checkbox"]'); + expect(inputWithId.attributes('id')).toBe(providedId); + expect(wrapperWithId.find('label.switch').attributes('for')).toBe(providedId); + + const wrapperWithoutId = mount(VToggleSwitch, { props: { modelValue: false } }); + const inputWithoutId = wrapperWithoutId.find('input[type="checkbox"]'); + const generatedId = inputWithoutId.attributes('id'); + expect(generatedId).toMatch(/^v-toggle-switch-/); + expect(wrapperWithoutId.find('label.switch').attributes('for')).toBe(generatedId); + }); + + it('renders accessible label (sr-only) from prop or default', () => { + const labelText = 'Enable High Contrast Mode'; + const wrapperWithLabel = mount(VToggleSwitch, { + props: { modelValue: false, label: labelText, id: 'label-switch' }, + }); + const srLabel1 = wrapperWithLabel.find('label.switch > span.sr-only'); + expect(srLabel1.exists()).toBe(true); + expect(srLabel1.text()).toBe(labelText); + + const wrapperDefaultLabel = mount(VToggleSwitch, { props: { modelValue: false, id: 'default-label-switch' } }); + const srLabel2 = wrapperDefaultLabel.find('label.switch > span.sr-only'); + expect(srLabel2.exists()).toBe(true); + expect(srLabel2.text()).toBe('Toggle Switch'); // Default label + }); + + it('input has role="switch"', () => { + const wrapper = mount(VToggleSwitch, { props: { modelValue: false, id: 'role-switch' } }); + expect(wrapper.find('input[type="checkbox"]').attributes('role')).toBe('switch'); + }); + + it('has .switch-container and label.switch classes', () => { + const wrapper = mount(VToggleSwitch, { props: { modelValue: false, id: 'class-switch' } }); + expect(wrapper.find('.switch-container').exists()).toBe(true); + expect(wrapper.find('label.switch').exists()).toBe(true); + }); + + it('renders onText when modelValue is true and onText is provided', () => { + const wrapper = mount(VToggleSwitch, { + props: { modelValue: true, onText: 'ON', offText: 'OFF', id: 'on-text-switch' } + }); + const onTextView = wrapper.find('.switch-text-on'); + expect(onTextView.exists()).toBe(true); + expect(onTextView.text()).toBe('ON'); + expect(wrapper.find('.switch-text-off').exists()).toBe(false); + }); + + it('renders offText when modelValue is false and offText is provided', () => { + const wrapper = mount(VToggleSwitch, { + props: { modelValue: false, onText: 'ON', offText: 'OFF', id: 'off-text-switch' } + }); + const offTextView = wrapper.find('.switch-text-off'); + expect(offTextView.exists()).toBe(true); + expect(offTextView.text()).toBe('OFF'); + expect(wrapper.find('.switch-text-on').exists()).toBe(false); + }); + + it('does not render onText/offText if not provided', () => { + const wrapper = mount(VToggleSwitch, { props: { modelValue: false, id: 'no-text-switch' } }); + expect(wrapper.find('.switch-text-on').exists()).toBe(false); + expect(wrapper.find('.switch-text-off').exists()).toBe(false); + }); +}); diff --git a/fe/src/components/valerie/VToggleSwitch.stories.ts b/fe/src/components/valerie/VToggleSwitch.stories.ts new file mode 100644 index 0000000..a100794 --- /dev/null +++ b/fe/src/components/valerie/VToggleSwitch.stories.ts @@ -0,0 +1,138 @@ +import VToggleSwitch from './VToggleSwitch.vue'; +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref, watch } from 'vue'; + +const meta: Meta = { + title: 'Valerie/VToggleSwitch', + component: VToggleSwitch, + tags: ['autodocs'], + argTypes: { + modelValue: { control: 'boolean', description: 'State of the toggle (v-model).' }, + disabled: { control: 'boolean' }, + id: { control: 'text' }, + label: { control: 'text', description: 'Accessible label (visually hidden).' }, + onText: { control: 'text', description: 'Text for ON state (inside switch).' }, + offText: { control: 'text', description: 'Text for OFF state (inside switch).' }, + // Events + 'update:modelValue': { action: 'update:modelValue', table: {disable: true} }, + }, + parameters: { + docs: { + description: { + component: 'A toggle switch component, often used for boolean settings. It uses a hidden checkbox input for accessibility and state management, and custom styling for appearance.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Template for managing v-model in stories +const VModelTemplate: Story = { + render: (args) => ({ + components: { VToggleSwitch }, + setup() { + const switchState = ref(args.modelValue); + watch(() => args.modelValue, (newVal) => { + switchState.value = newVal; + }); + const onUpdateModelValue = (val: boolean) => { + switchState.value = val; + // args.modelValue = val; // Update Storybook arg + }; + return { ...args, switchState, onUpdateModelValue }; + }, + template: ` +
      + + Current state: {{ switchState ? 'ON' : 'OFF' }} +
      + `, + }), +}; + +export const Basic: Story = { + ...VModelTemplate, + args: { + modelValue: false, + id: 'basic-toggle', + label: 'Enable feature', + }, +}; + +export const DefaultOn: Story = { + ...VModelTemplate, + args: { + modelValue: true, + id: 'default-on-toggle', + label: 'Notifications enabled', + }, +}; + +export const DisabledOff: Story = { + ...VModelTemplate, + args: { + modelValue: false, + disabled: true, + id: 'disabled-off-toggle', + label: 'Feature disabled', + }, +}; + +export const DisabledOn: Story = { + ...VModelTemplate, + args: { + modelValue: true, + disabled: true, + id: 'disabled-on-toggle', + label: 'Setting locked on', + }, +}; + +export const WithCustomIdAndLabel: Story = { + ...VModelTemplate, + args: { + modelValue: false, + id: 'custom-id-for-toggle', + label: 'Subscribe to advanced updates', + }, +}; + +export const WithOnOffText: Story = { + ...VModelTemplate, + args: { + modelValue: true, + id: 'text-toggle', + label: 'Mode selection', + onText: 'ON', + offText: 'OFF', + }, + parameters: { + docs: { + description: { story: 'Displays "ON" or "OFF" text within the switch. Note: This requires appropriate styling in `VToggleSwitch.vue` to position the text correctly and may need adjustments based on design specifics for text visibility and overlap with the thumb.' }, + }, + }, +}; + +export const NoProvidedLabel: Story = { + ...VModelTemplate, + args: { + modelValue: false, + id: 'no-label-toggle', + // label prop is not set, will use default 'Toggle Switch' + }, + parameters: { + docs: { + description: { story: 'When `label` prop is not provided, it defaults to "Toggle Switch" for accessibility. This label is visually hidden but available to screen readers.' }, + }, + }, +}; diff --git a/fe/src/components/valerie/VToggleSwitch.vue b/fe/src/components/valerie/VToggleSwitch.vue new file mode 100644 index 0000000..f137543 --- /dev/null +++ b/fe/src/components/valerie/VToggleSwitch.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/fe/src/components/valerie/tabs/Tabs.stories.ts b/fe/src/components/valerie/tabs/Tabs.stories.ts new file mode 100644 index 0000000..267b63f --- /dev/null +++ b/fe/src/components/valerie/tabs/Tabs.stories.ts @@ -0,0 +1,206 @@ +import VTabs from './VTabs.vue'; +import VTabList from './VTabList.vue'; +import VTab from './VTab.vue'; +import VTabPanels from './VTabPanels.vue'; +import VTabPanel from './VTabPanel.vue'; +import VButton from '../VButton.vue'; // For v-model interaction demo + +import type { Meta, StoryObj } from '@storybook/vue3'; +import { ref, computed } from 'vue'; + +const meta: Meta = { + title: 'Valerie/Tabs System', + component: VTabs, + subcomponents: { VTabList, VTab, VTabPanels, VTabPanel }, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'A flexible and accessible tabs system composed of `VTabs`, `VTabList`, `VTab`, `VTabPanels`, and `VTabPanel`. Use `v-model` on `VTabs` to control the active tab.', + }, + }, + }, + argTypes: { + modelValue: { control: 'select', options: ['profile', 'settings', 'billing', null], description: 'ID of the active tab (for v-model).' }, + initialTab: { control: 'select', options: ['profile', 'settings', 'billing', null], description: 'ID of the initially active tab.' }, + } +}; + +export default meta; +type Story = StoryObj; + +export const BasicTabs: Story = { + render: (args) => ({ + components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel }, + setup() { + // For stories not directly testing v-model, manage active tab locally or use initialTab + const currentTab = ref(args.modelValue || args.initialTab || 'profile'); + return { args, currentTab }; + }, + template: ` + + + + + + + + + +

      Profile Tab Content: Information about the user.

      + +
      + +

      Settings Tab Content: Configuration options.

      + +
      + +

      Billing Tab Content: Payment methods and history.

      + Add Payment Method +
      + +

      This panel should not be reachable if the tab is truly disabled.

      +
      +
      +
      + `, + }), + args: { + // modelValue: 'profile', // Let VTabs default logic or initialTab handle it + initialTab: 'settings', + }, +}; + +export const WithCustomTabContent: Story = { + render: (args) => ({ + components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel, VIcon: meta.subcomponents.VIcon }, // Assuming VIcon for demo + setup() { + const currentTab = ref(args.initialTab || 'alerts'); + return { args, currentTab }; + }, + template: ` + + + + + 🔔 Alerts + + + Messages 3 + + + + +

      Content for Alerts. Example of custom content in VTab.

      +
      + +

      Content for Messages. Also has custom content in its VTab.

      +
        +
      • Message 1
      • +
      • Message 2
      • +
      • Message 3
      • +
      +
      +
      +
      + `, + }), + args: { + initialTab: 'alerts', + } +}; + +export const VModelInteraction: Story = { + render: (args) => ({ + components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel, VButton }, + setup() { + // This story uses args.modelValue directly, which Storybook controls can manipulate. + // Vue's v-model on the component will work with Storybook's arg system. + const currentTab = ref(args.modelValue || 'first'); + watch(() => args.modelValue, (newVal) => { // Keep local ref in sync if arg changes externally + if (newVal !== undefined && newVal !== null) currentTab.value = newVal; + }); + + const availableTabs = ['first', 'second', 'third']; + const selectNextTab = () => { + const currentIndex = availableTabs.indexOf(currentTab.value); + const nextIndex = (currentIndex + 1) % availableTabs.length; + currentTab.value = availableTabs[nextIndex]; + // args.modelValue = availableTabs[nextIndex]; // Update arg for SB control + }; + + return { args, currentTab, selectNextTab, availableTabs }; + }, + template: ` +
      + + + + + + +

      Content for {{ tabId }} tab.

      +
      +
      +
      +
      +

      Current active tab (v-model): {{ currentTab || 'None' }}

      + Select Next Tab Programmatically +

      Note: Storybook control for 'modelValue' can also change the tab.

      +
      +
      + `, + }), + args: { + modelValue: 'first', // Initial value for the v-model + }, +}; + +export const EmptyTabs: Story = { + render: (args) => ({ + components: { VTabs, VTabList, VTabPanels }, + setup() { return { args }; }, + template: ` + + + + + + + `, + }), + args: { + modelValue: null, + }, + parameters: { + docs: { + description: { story: 'Demonstrates the Tabs system with no tabs or panels defined. The `VTabs` onMounted logic should handle this gracefully (no default tab selected).' }, + }, + }, +}; + +export const TabsWithOnlyOnePanel: Story = { + render: (args) => ({ + components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel }, + setup() { + const currentTab = ref(args.modelValue || 'single'); + return { args, currentTab }; + }, + template: ` + + + + + + +

      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`.

      +
      +
      +
      + `, + }), + args: { + // modelValue: 'single', // Let default selection logic work + }, +}; diff --git a/fe/src/components/valerie/tabs/VTab.spec.ts b/fe/src/components/valerie/tabs/VTab.spec.ts new file mode 100644 index 0000000..df872fe --- /dev/null +++ b/fe/src/components/valerie/tabs/VTab.spec.ts @@ -0,0 +1,108 @@ +import { mount } from '@vue/test-utils'; +import VTab from './VTab.vue'; +import { TabsProviderKey } from './types'; +import { ref } from 'vue'; +import type { TabId, TabsContext } from './types'; + +// Mock a VTabs provider for VTab tests +const mockTabsContext = (activeTabIdValue: TabId | null = 'test1'): TabsContext => ({ + activeTabId: ref(activeTabIdValue), + selectTab: vi.fn(), +}); + +describe('VTab.vue', () => { + it('renders title prop when no default slot', () => { + const wrapper = mount(VTab, { + props: { id: 'tab1', title: 'My Tab Title' }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext() } }, + }); + expect(wrapper.text()).toBe('My Tab Title'); + }); + + it('renders default slot content instead of title prop', () => { + const slotContent = 'Custom Tab Content'; + const wrapper = mount(VTab, { + props: { id: 'tab1', title: 'Ignored Title' }, + slots: { default: slotContent }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext() } }, + }); + expect(wrapper.html()).toContain(slotContent); + expect(wrapper.text()).not.toBe('Ignored Title'); + }); + + it('computes isActive correctly', () => { + const context = mockTabsContext('activeTab'); + const wrapper = mount(VTab, { + props: { id: 'activeTab' }, + global: { provide: { [TabsProviderKey as any]: context } }, + }); + expect(wrapper.vm.isActive).toBe(true); + expect(wrapper.classes()).toContain('active'); + expect(wrapper.attributes('aria-selected')).toBe('true'); + + const wrapperInactive = mount(VTab, { + props: { id: 'inactiveTab' }, + global: { provide: { [TabsProviderKey as any]: context } }, + }); + expect(wrapperInactive.vm.isActive).toBe(false); + expect(wrapperInactive.classes()).not.toContain('active'); + expect(wrapperInactive.attributes('aria-selected')).toBe('false'); + }); + + it('calls selectTab with its id on click if not disabled', async () => { + const context = mockTabsContext('anotherTab'); + const wrapper = mount(VTab, { + props: { id: 'clickableTab', title: 'Click Me' }, + global: { provide: { [TabsProviderKey as any]: context } }, + }); + await wrapper.trigger('click'); + expect(context.selectTab).toHaveBeenCalledWith('clickableTab'); + }); + + it('does not call selectTab on click if disabled', async () => { + const context = mockTabsContext(); + const wrapper = mount(VTab, { + props: { id: 'disabledTab', title: 'Disabled', disabled: true }, + global: { provide: { [TabsProviderKey as any]: context } }, + }); + await wrapper.trigger('click'); + expect(context.selectTab).not.toHaveBeenCalled(); + }); + + it('applies disabled attribute and class when disabled prop is true', () => { + const wrapper = mount(VTab, { + props: { id: 'tab1', disabled: true }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext() } }, + }); + expect(wrapper.attributes('disabled')).toBeDefined(); + expect(wrapper.classes()).toContain('disabled'); + }); + + it('sets ARIA attributes correctly', () => { + const wrapper = mount(VTab, { + props: { id: 'contactTab' }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext('contactTab') } }, + }); + expect(wrapper.attributes('role')).toBe('tab'); + expect(wrapper.attributes('id')).toBe('tab-contactTab'); + expect(wrapper.attributes('aria-controls')).toBe('panel-contactTab'); + expect(wrapper.attributes('aria-selected')).toBe('true'); + expect(wrapper.attributes('tabindex')).toBe('0'); // Active tab should be tabbable + }); + + it('sets tabindex to -1 for inactive tabs', () => { + const wrapper = mount(VTab, { + props: { id: 'inactiveContactTab' }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext('activeTab') } }, // Different active tab + }); + expect(wrapper.attributes('tabindex')).toBe('-1'); + }); + + + it('throws error if not used within VTabs (no context provided)', () => { + // Prevent console error from Vue about missing provide + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(()_ => {}); + expect(() => mount(VTab, { props: { id: 'tab1' } })).toThrow('VTab must be used within a VTabs component.'); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/fe/src/components/valerie/tabs/VTab.vue b/fe/src/components/valerie/tabs/VTab.vue new file mode 100644 index 0000000..36d7d75 --- /dev/null +++ b/fe/src/components/valerie/tabs/VTab.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/fe/src/components/valerie/tabs/VTabList.vue b/fe/src/components/valerie/tabs/VTabList.vue new file mode 100644 index 0000000..b5671a9 --- /dev/null +++ b/fe/src/components/valerie/tabs/VTabList.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/fe/src/components/valerie/tabs/VTabListAndPanels.spec.ts b/fe/src/components/valerie/tabs/VTabListAndPanels.spec.ts new file mode 100644 index 0000000..75d2ee2 --- /dev/null +++ b/fe/src/components/valerie/tabs/VTabListAndPanels.spec.ts @@ -0,0 +1,35 @@ +import { mount } from '@vue/test-utils'; +import VTabList from './VTabList.vue'; +import VTabPanels from './VTabPanels.vue'; +import { describe, it, expect } from 'vitest'; + +describe('VTabList.vue', () => { + it('renders default slot content', () => { + const slotContent = ''; + const wrapper = mount(VTabList, { + slots: { default: slotContent }, + }); + expect(wrapper.html()).toContain(slotContent); + }); + + it('has role="tablist" and class .tab-list', () => { + const wrapper = mount(VTabList); + expect(wrapper.attributes('role')).toBe('tablist'); + expect(wrapper.classes()).toContain('tab-list'); + }); +}); + +describe('VTabPanels.vue', () => { + it('renders default slot content', () => { + const slotContent = '
      Panel 1 Content
      Panel 2 Content
      '; + const wrapper = mount(VTabPanels, { + slots: { default: slotContent }, + }); + expect(wrapper.html()).toContain(slotContent); + }); + + it('has class .tab-panels-container', () => { + const wrapper = mount(VTabPanels); + expect(wrapper.classes()).toContain('tab-panels-container'); + }); +}); diff --git a/fe/src/components/valerie/tabs/VTabPanel.spec.ts b/fe/src/components/valerie/tabs/VTabPanel.spec.ts new file mode 100644 index 0000000..42114fa --- /dev/null +++ b/fe/src/components/valerie/tabs/VTabPanel.spec.ts @@ -0,0 +1,72 @@ +import { mount } from '@vue/test-utils'; +import VTabPanel from './VTabPanel.vue'; +import { TabsProviderKey } from './types'; +import { ref } from 'vue'; +import type { TabId, TabsContext } from './types'; + +// Mock a VTabs provider for VTabPanel tests +const mockTabsContext = (activeTabIdValue: TabId | null = 'panelTest1'): TabsContext => ({ + activeTabId: ref(activeTabIdValue), + selectTab: vi.fn(), // Not used by VTabPanel, but part of context +}); + +describe('VTabPanel.vue', () => { + it('renders default slot content', () => { + const panelContent = '
      Panel Content Here
      '; + const wrapper = mount(VTabPanel, { + props: { id: 'panel1' }, + slots: { default: panelContent }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext('panel1') } }, + }); + expect(wrapper.html()).toContain(panelContent); + }); + + it('is visible when its id matches activeTabId (isActive is true)', () => { + const wrapper = mount(VTabPanel, { + props: { id: 'activePanel' }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext('activePanel') } }, + }); + // v-show means the element is still rendered, but display: none is applied by Vue if false + expect(wrapper.vm.isActive).toBe(true); + expect(wrapper.element.style.display).not.toBe('none'); + }); + + it('is hidden (display: none) when its id does not match activeTabId (isActive is false)', () => { + const wrapper = mount(VTabPanel, { + props: { id: 'inactivePanel' }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext('someOtherActivePanel') } }, + }); + expect(wrapper.vm.isActive).toBe(false); + // Vue applies display: none for v-show="false" + // Note: this might be an internal detail of Vue's v-show. + // A more robust test might be to check `wrapper.isVisible()` if available and configured. + // For now, checking the style attribute is a common way. + expect(wrapper.element.style.display).toBe('none'); + }); + + it('sets ARIA attributes correctly', () => { + const panelId = 'infoPanel'; + const wrapper = mount(VTabPanel, { + props: { id: panelId }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext(panelId) } }, + }); + expect(wrapper.attributes('role')).toBe('tabpanel'); + expect(wrapper.attributes('id')).toBe(`panel-${panelId}`); + expect(wrapper.attributes('aria-labelledby')).toBe(`tab-${panelId}`); + expect(wrapper.attributes('tabindex')).toBe('0'); // Panel should be focusable + }); + + it('applies .tab-content class', () => { + const wrapper = mount(VTabPanel, { + props: { id: 'anyPanel' }, + global: { provide: { [TabsProviderKey as any]: mockTabsContext('anyPanel') } }, + }); + expect(wrapper.classes()).toContain('tab-content'); + }); + + it('throws error if not used within VTabs (no context provided)', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(()_ => {}); + expect(() => mount(VTabPanel, { props: { id: 'panel1' } })).toThrow('VTabPanel must be used within a VTabs component.'); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/fe/src/components/valerie/tabs/VTabPanel.vue b/fe/src/components/valerie/tabs/VTabPanel.vue new file mode 100644 index 0000000..9ccbe73 --- /dev/null +++ b/fe/src/components/valerie/tabs/VTabPanel.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/fe/src/components/valerie/tabs/VTabPanels.vue b/fe/src/components/valerie/tabs/VTabPanels.vue new file mode 100644 index 0000000..888ad29 --- /dev/null +++ b/fe/src/components/valerie/tabs/VTabPanels.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/fe/src/components/valerie/tabs/VTabs.spec.ts b/fe/src/components/valerie/tabs/VTabs.spec.ts new file mode 100644 index 0000000..1e2902d --- /dev/null +++ b/fe/src/components/valerie/tabs/VTabs.spec.ts @@ -0,0 +1,135 @@ +import { mount } from '@vue/test-utils'; +import VTabs from './VTabs.vue'; +import VTab from './VTab.vue'; +import VTabList from './VTabList.vue'; +import VTabPanel from './VTabPanel.vue'; +import VTabPanels from './VTabPanels.vue'; +import { TabsProviderKey } from './types'; +import { nextTick, h } from 'vue'; + +// Helper to create a minimal tabs structure for testing VTabs logic +const createBasicTabsStructure = (activeTabId: string | null = 'tab1') => { + return { + components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel }, + template: ` + + + + + + +

      Content 1

      +

      Content 2

      +
      +
      + `, + data() { + return { + currentModelValue: activeTabId, + initialTabValue: activeTabId, // Can be overridden in test + }; + }, + }; +}; + + +describe('VTabs.vue', () => { + it('initializes activeTabId with modelValue', () => { + const wrapper = mount(VTabs, { + props: { modelValue: 'second' }, + slots: { default: '' }, + global: { components: { VTabList, VTab, VTabPanels, VTabPanel } } // Stubbing children + }); + const context = wrapper.vm.$.provides[TabsProviderKey as any]; + expect(context.activeTabId.value).toBe('second'); + }); + + it('initializes activeTabId with initialTab if modelValue is not provided', () => { + const wrapper = mount(VTabs, { + props: { initialTab: 'third' }, + slots: { default: '' }, + global: { components: { VTabList, VTab, VTabPanels, VTabPanel } } + }); + const context = wrapper.vm.$.provides[TabsProviderKey as any]; + expect(context.activeTabId.value).toBe('third'); + }); + + it('updates activeTabId when modelValue prop changes', async () => { + const wrapper = mount(VTabs, { + props: { modelValue: 'one' }, + slots: { default: '' }, + global: { components: { VTabList, VTab, VTabPanels, VTabPanel } } + }); + const context = wrapper.vm.$.provides[TabsProviderKey as any]; + expect(context.activeTabId.value).toBe('one'); + await wrapper.setProps({ modelValue: 'two' }); + expect(context.activeTabId.value).toBe('two'); + }); + + it('emits update:modelValue when selectTab is called', async () => { + const wrapper = mount(VTabs, { + props: { modelValue: 'alpha' }, + slots: { default: '' }, + global: { components: { VTabList, VTab, VTabPanels, VTabPanel } } + }); + const context = wrapper.vm.$.provides[TabsProviderKey as any]; + context.selectTab('beta'); + await nextTick(); + expect(wrapper.emitted()['update:modelValue']).toBeTruthy(); + expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['beta']); + expect(context.activeTabId.value).toBe('beta'); + }); + + it('selects the first tab if no modelValue or initialTab is provided on mount', async () => { + // This test is more involved as it requires inspecting slot children + // We need to ensure VTab components are actually rendered within the slots + const TestComponent = { + components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel }, + template: ` + + + + + + + Content First + Content Second + + + `, + }; + const wrapper = mount(TestComponent); + await nextTick(); // Wait for onMounted hook in VTabs + + // Access VTabs instance to check its internal activeTabId via provided context + const vTabsInstance = wrapper.findComponent(VTabs); + const context = vTabsInstance.vm.$.provides[TabsProviderKey as any]; + expect(context.activeTabId.value).toBe('firstMounted'); + }); + + it('does not change activeTabId if modelValue is explicitly null and no initialTab', async () => { + const TestComponent = { + components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel }, + template: ` + + + + + `, + }; + const wrapper = mount(TestComponent); + await nextTick(); + const vTabsInstance = wrapper.findComponent(VTabs); + const context = vTabsInstance.vm.$.provides[TabsProviderKey as any]; + expect(context.activeTabId.value).toBeNull(); // Should remain null, not default to first tab + }); + + + it('renders its default slot content', () => { + const wrapper = mount(VTabs, { + slots: { default: '
      Hello
      ' }, + }); + expect(wrapper.find('.test-slot-content').exists()).toBe(true); + expect(wrapper.text()).toContain('Hello'); + }); +}); diff --git a/fe/src/components/valerie/tabs/VTabs.vue b/fe/src/components/valerie/tabs/VTabs.vue new file mode 100644 index 0000000..6e5ca88 --- /dev/null +++ b/fe/src/components/valerie/tabs/VTabs.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/fe/src/components/valerie/tabs/types.ts b/fe/src/components/valerie/tabs/types.ts new file mode 100644 index 0000000..2a09e31 --- /dev/null +++ b/fe/src/components/valerie/tabs/types.ts @@ -0,0 +1,10 @@ +import type { Ref, InjectionKey } from 'vue'; + +export type TabId = string | number; + +export interface TabsContext { + activeTabId: Ref; + selectTab: (id: TabId) => void; +} + +export const TabsProviderKey: InjectionKey = Symbol('VTabs');