Jules was unable to complete the task in time. Please review the work done so far and provide feedback for Jules to continue.

This commit is contained in:
google-labs-jules[bot] 2025-05-31 13:37:30 +00:00 committed by mohamad
parent 3811dc7ee5
commit fc16f169b1
63 changed files with 8086 additions and 0 deletions

View File

@ -0,0 +1 @@
# This is a placeholder file to create the directory.

View File

@ -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: '<i :class="`mock-icon icon-${name}`"></i>',
}));
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 = '<strong>Custom Alert Content</strong>';
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 = '<button>Retry</button>';
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);
});
});

View File

@ -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<typeof VAlert> = {
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<typeof VAlert>;
// 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: `
<div>
<VAlert
:modelValue="isVisible"
@update:modelValue="onUpdateModelValue"
:message="message"
:type="type"
:closable="closable"
:icon="icon"
:showIcon="showIcon"
@close="() => $emit('close')"
>
<template #default v-if="args.customDefaultSlot">
<slot name="storyDefaultContent"></slot>
</template>
<template #actions v-if="args.showActionsSlot">
<slot name="storyActionsContent"></slot>
</template>
</VAlert>
<VButton v-if="!isVisible && closable" @click="resetAlert" size="sm" style="margin-top: 10px;">
Show Alert Again
</VButton>
</div>
`,
}),
};
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: `
<div>
<VAlert
:modelValue="isVisible"
@update:modelValue="onUpdateModelValue"
:type="type"
:closable="closable"
:icon="icon"
:showIcon="showIcon"
>
<h4>Custom Title via Slot!</h4>
<p>This is <strong>bold text</strong> and <em>italic text</em> inside the alert's default slot.</p>
<p>It overrides the 'message' prop.</p>
</VAlert>
<VButton v-if="!isVisible && closable" @click="resetAlert" size="sm" style="margin-top: 10px;">
Show Alert Again
</VButton>
</div>
`
}),
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: `
<div>
<VAlert
:modelValue="isVisible"
@update:modelValue="onUpdateModelValue"
:message="message"
:type="type"
:closable="closable"
>
<template #actions>
<VButton :variant="type === 'error' || type === 'warning' ? 'danger' : 'primary'" size="sm" @click="() => alert('Primary action clicked!')">
Take Action
</VButton>
<VButton variant="neutral" size="sm" @click="onUpdateModelValue(false)">
Dismiss
</VButton>
</template>
</VAlert>
<VButton v-if="!isVisible && (closable || args.showActionsSlot)" @click="resetAlert" size="sm" style="margin-top: 10px;">
Show Alert Again
</VButton>
</div>
`
}),
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,
},
};

View File

@ -0,0 +1,184 @@
<template>
<Transition name="alert-fade">
<div v-if="internalModelValue" class="alert" :class="alertClasses" role="alert">
<div class="alert-main-section">
<VIcon v-if="showIcon && displayIconName" :name="displayIconName" class="alert-icon" />
<div class="alert-content">
<slot>{{ message }}</slot>
</div>
<button
v-if="closable"
type="button"
class="alert-close-btn"
aria-label="Close alert"
@click="handleClose"
>
<VIcon name="close" />
</button>
</div>
<div v-if="$slots.actions" class="alert-actions">
<slot name="actions"></slot>
</div>
</div>
</Transition>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon is available
const props = defineProps({
modelValue: { // For v-model for dismissible alerts
type: Boolean,
default: true,
},
message: {
type: String,
default: '',
},
type: {
type: String, // 'success', 'warning', 'error', 'info'
default: 'info',
validator: (value: string) => ['success', 'warning', 'error', 'info'].includes(value),
},
closable: {
type: Boolean,
default: false,
},
icon: { // Custom icon name
type: String,
default: null,
},
showIcon: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update:modelValue', 'close']);
// Internal state for visibility, to allow closing even if not using v-model
const internalModelValue = ref(props.modelValue);
watch(() => props.modelValue, (newVal) => {
internalModelValue.value = newVal;
});
const alertClasses = computed(() => [
`alert-${props.type}`,
// Add other classes based on props if needed, e.g., for icon presence
]);
const defaultIcons: Record<string, string> = {
success: 'check-circle',
warning: 'alert-triangle',
error: 'alert-octagon', // Or 'x-octagon' / 'alert-circle'
info: 'info-circle', // Or 'info' / 'bell'
};
const displayIconName = computed(() => {
if (!props.showIcon) return null;
return props.icon || defaultIcons[props.type] || 'info-circle'; // Fallback if type is somehow invalid
});
const handleClose = () => {
internalModelValue.value = false; // Hide it internally
emit('update:modelValue', false); // Emit for v-model
emit('close');
};
</script>
<style lang="scss" scoped>
.alert {
display: flex;
flex-direction: column;
padding: 0.75rem 1.25rem; // Default padding
margin-bottom: 1rem;
border: 1px solid transparent;
border-radius: 0.25rem; // Standard border radius
// Default alert type (info)
// These colors should align with valerie-ui.scss variables
color: #0c5460; // Example: $info-text
background-color: #d1ecf1; // Example: $info-bg
border-color: #bee5eb; // Example: $info-border
&.alert-success {
color: #155724; // $success-text
background-color: #d4edda; // $success-bg
border-color: #c3e6cb; // $success-border
}
&.alert-warning {
color: #856404; // $warning-text
background-color: #fff3cd; // $warning-bg
border-color: #ffeeba; // $warning-border
}
&.alert-error {
color: #721c24; // $danger-text
background-color: #f8d7da; // $danger-bg
border-color: #f5c6cb; // $danger-border
}
// Info is the default if no specific class matches
}
.alert-main-section {
display: flex;
align-items: flex-start; // Align icon with the start of the text
}
.alert-icon {
// margin-right: 0.75rem;
// font-size: 1.25em; // Make icon slightly larger than text
// Using VIcon size prop might be better.
// For now, let's assume VIcon itself has appropriate default sizing or takes it from font-size.
flex-shrink: 0; // Prevent icon from shrinking
margin-right: 0.8em;
margin-top: 0.1em; // Fine-tune vertical alignment with text
// Default icon color can be inherited or set explicitly if needed
// e.g., color: currentColor; (though VIcon might handle this)
}
.alert-content {
flex-grow: 1; // Message area takes available space
// line-height: 1.5; // Ensure good readability
}
.alert-close-btn {
background: transparent;
border: none;
color: inherit; // Inherit color from parent alert type for better contrast
opacity: 0.7;
padding: 0 0.5rem; // Minimal padding
margin-left: 1rem; // Space it from the content
font-size: 1.2rem; // Adjust VIcon size if needed via this
line-height: 1;
cursor: pointer;
&:hover {
opacity: 1;
}
// VIcon specific styling if needed
// ::v-deep(.icon) { font-size: 1em; }
}
.alert-actions {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(0,0,0,0.1); // Separator for actions
display: flex;
justify-content: flex-end; // Align actions to the right
gap: 0.5rem;
}
// Transition for fade in/out
.alert-fade-enter-active,
.alert-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.alert-fade-enter-from,
.alert-fade-leave-to {
opacity: 0;
transform: translateY(-10px); // Optional: slight slide effect
}
</style>

View File

@ -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: '<VIcon name="user" />', // 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: '<span>Fallback Slot</span>' },
});
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
});
});

View File

@ -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<typeof VAvatar> = {
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<typeof VAvatar>;
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: `
<VAvatar v-bind="args">
<VIcon name="alert" style="font-size: 20px; color: #007AFF;" />
</VAvatar>
`,
}),
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: `
<VAvatar v-bind="args">
<VIcon name="search" style="font-size: 20px; color: #6c757d;" />
</VAvatar>
`,
}),
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: `
<VAvatar v-bind="args">
<span>?</span>
</VAvatar>
`,
}),
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: '<div style="--avatar-size: 80px;"><story/></div>' })],
// 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: `<VAvatar v-bind="args" style="width: 80px; height: 80px; font-size: 1.5em;" />`,
}),
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.',
},
},
},
};

View File

@ -0,0 +1,92 @@
<template>
<div class="avatar">
<img v-if="src" :src="src" :alt="alt" class="avatar-img" @error="handleImageError" />
<span v-else-if="initials" class="avatar-initials">{{ initials }}</span>
<slot v-else></slot>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch } from 'vue';
export default defineComponent({
name: 'VAvatar',
props: {
src: {
type: String,
default: null,
},
initials: {
type: String,
default: null,
},
alt: {
type: String,
default: 'Avatar',
},
},
setup(props, { slots }) {
// Optional: Handle image loading errors, e.g., to show initials or slot content as a fallback
const imageError = ref(false);
const handleImageError = () => {
imageError.value = true;
};
watch(() => props.src, (newSrc) => {
if (newSrc) {
imageError.value = false; // Reset error state when src changes
}
});
// This computed prop is not strictly necessary for the template logic above,
// but can be useful if template logic becomes more complex or for debugging.
const showImage = computed(() => props.src && !imageError.value);
const showInitials = computed(() => !showImage.value && props.initials);
const showSlot = computed(() => !showImage.value && !showInitials.value && slots.default);
return {
handleImageError,
// expose computed if needed by a more complex template
// showImage,
// showInitials,
// showSlot,
};
},
});
</script>
<style lang="scss" scoped>
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px; // Default size, can be made a prop or customized via CSS
height: 40px; // Default size
border-radius: 50%;
background-color: #E9ECEF; // Placeholder background, customize as needed (e.g., Gray-200)
color: #495057; // Placeholder text color (e.g., Gray-700)
font-weight: 500;
overflow: hidden; // Ensure content (like images) is clipped to the circle
vertical-align: middle; // Better alignment with surrounding text/elements
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover; // Ensures the image covers the area without distortion
}
.avatar-initials {
font-size: 0.9em; // Adjust based on avatar size and desired text appearance
line-height: 1; // Ensure initials are centered vertically
text-transform: uppercase;
}
// If using an icon via slot, you might want to style it too
// Example:
// ::v-deep(svg), ::v-deep(.icon) { // if slot contains an icon component or raw svg
// width: 60%;
// height: 60%;
// }
}
</style>

View File

@ -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();
});
});

View File

@ -0,0 +1,96 @@
import VBadge from './VBadge.vue';
import type { Meta, StoryObj } from '@storybook/vue3';
const meta: Meta<typeof VBadge> = {
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<typeof VBadge>;
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: `
<div style="position: relative; width: 100px; height: 30px; border: 1px dashed #ccc; padding: 10px; margin-top: 10px; margin-left:10px;">
Parent Element
<story />
</div>
`,
}),
],
// 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
},
};

View File

@ -0,0 +1,107 @@
<template>
<span :class="badgeClasses">{{ text }}</span>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
type BadgeVariant = 'secondary' | 'accent' | 'settled' | 'pending';
export default defineComponent({
name: 'VBadge',
props: {
text: {
type: String,
required: true,
},
variant: {
type: String as PropType<BadgeVariant>,
default: 'secondary',
validator: (value: string) => ['secondary', 'accent', 'settled', 'pending'].includes(value),
},
sticky: {
type: Boolean,
default: false,
},
},
setup(props) {
const badgeClasses = computed(() => {
const classes = [
'item-badge', // Base class from the design document
`badge-${props.variant}`,
];
// The design doc mentions: "Sticky: (Accent variant only)"
if (props.sticky && props.variant === 'accent') {
classes.push('badge-sticky');
}
return classes;
});
return {
badgeClasses,
};
},
});
</script>
<style lang="scss" scoped>
// Base styles from the design document
.item-badge {
display: inline-flex;
padding: 2px 8px; // 2px 8px from design
border-radius: 16px; // 16px from design
font-weight: 500; // Medium
font-size: 12px; // 12px from design
line-height: 16px; // 16px from design
text-align: center;
white-space: nowrap;
}
// Variants from the design document
.badge-secondary {
background-color: #E9ECEF; // Gray-100
color: #495057; // Gray-700
}
.badge-accent {
background-color: #E6F7FF; // Primary-50
color: #007AFF; // Primary-500
}
.badge-settled {
background-color: #E6F7F0; // Success-50 (assuming, based on typical color schemes)
color: #28A745; // Success-700 (assuming)
// Design doc has #198754 (Success-600) for text and #D1E7DD (Success-100) for background. Let's use those.
background-color: #D1E7DD;
color: #198754;
}
.badge-pending {
background-color: #FFF3E0; // Warning-50 (assuming)
color: #FFA500; // Warning-700 (assuming)
// Design doc has #FFC107 (Warning-500) for text and #FFF3CD (Warning-100) for background. Let's use those.
background-color: #FFF3CD;
color: #FFC107; // Note: Design shows a darker text #FFA000 (Warning-600 like) but specifies #FFC107 for the color name.
// Using #FFC107 for now, can be adjusted.
}
// Sticky style for Accent variant
.badge-sticky {
// The design doc implies sticky might mean position: sticky, or just a visual treatment.
// For now, let's assume it's a visual cue or a class that could be used with position: sticky by parent.
// If it refers to a specific visual change for the badge itself when sticky:
// e.g., a border, a shadow, or slightly different padding/look.
// The image shows it on top right, which is a positioning concern, not just a style of the badge itself.
// Let's add a subtle visual difference for the story, can be refined.
// For now, we'll assume 'badge-sticky' is a marker class and parent component handles actual stickiness.
// If it's meant to be position:absolute like in the Figma, that shouldn't be part of this component directly.
// The design doc description for "Sticky" under "Accent" variant says:
// "position: absolute; top: -4px; right: -4px;"
// This kind of positioning is usually context-dependent and best handled by the parent.
// However, if VBadge is *always* used this way when sticky, it could be added.
// For now, I will make `badge-sticky` only apply a visual change, not absolute positioning.
// The parent component can use this class to apply positioning.
// Example: add a small border to distinguish it slightly when sticky.
border: 1px solid #007AFF; // Primary-500 (same as text color for accent)
}
</style>

View File

@ -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: '<i>Slot Content</i>',
},
});
expect(wrapper.html()).toContain('<i>Slot Content</i>');
});
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);
});
});

View File

@ -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<typeof VButton> = {
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: '<VIcon /><story/>' })], // This is one way, or ensure VButton registers it
};
export default meta;
type Story = StoryObj<typeof VButton>;
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: `
<VButton v-bind="args">
<em>Italic Text</em> & <VIcon name="alert" size="sm" />
</VButton>
`,
}),
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: '<form @submit.prevent="() => alert(\'Form Submitted!\')"><story/></form>' })],
};

View File

@ -0,0 +1,207 @@
<template>
<button
:type="type"
:class="buttonClasses"
:disabled="disabled"
@click="handleClick"
>
<VIcon v-if="iconLeft && !iconOnly" :name="iconLeft" :size="iconSize" class="mr-1" />
<VIcon v-if="iconOnly && (iconLeft || iconRight)" :name="iconNameForIconOnly" :size="iconSize" />
<span v-if="!iconOnly" :class="{ 'sr-only': iconOnly && (iconLeft || iconRight) }">
<slot>{{ label }}</slot>
</span>
<VIcon v-if="iconRight && !iconOnly" :name="iconRight" :size="iconSize" class="ml-1" />
</button>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon.vue is in the same directory
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg'; // Added 'lg' for consistency, though not in SCSS class list initially
type ButtonType = 'button' | 'submit' | 'reset';
export default defineComponent({
name: 'VButton',
components: {
VIcon,
},
props: {
label: {
type: String,
default: 'Button',
},
variant: {
type: String as PropType<ButtonVariant>,
default: 'primary',
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger'].includes(value),
},
size: {
type: String as PropType<ButtonSize>,
default: 'md',
validator: (value: string) => ['sm', 'md', 'lg'].includes(value),
},
disabled: {
type: Boolean,
default: false,
},
iconLeft: {
type: String,
default: null,
},
iconRight: {
type: String,
default: null,
},
iconOnly: {
type: Boolean,
default: false,
},
type: {
type: String as PropType<ButtonType>,
default: 'button',
validator: (value: string) => ['button', 'submit', 'reset'].includes(value),
},
},
emits: ['click'],
setup(props, { emit }) {
const buttonClasses = computed(() => {
const classes = [
'btn',
`btn-${props.variant}`,
`btn-${props.size}`,
];
if (props.iconOnly && (props.iconLeft || props.iconRight)) {
classes.push('btn-icon-only');
}
if (props.disabled) {
classes.push('btn-disabled'); // Assuming a general disabled class
}
return classes;
});
const iconSize = computed(() => {
// Adjust icon size based on button size, or define specific icon sizes
if (props.size === 'sm') return 'sm';
// if (props.size === 'lg') return 'lg'; // VIcon might not have lg, handle appropriately
return undefined; // VIcon default size
});
const iconNameForIconOnly = computed(() => {
return props.iconLeft || props.iconRight;
});
const handleClick = (event: MouseEvent) => {
if (!props.disabled) {
emit('click', event);
}
};
return {
buttonClasses,
iconSize,
iconNameForIconOnly,
handleClick,
};
},
});
</script>
<style lang="scss" scoped>
// Basic button styling - will be expanded in a later task
.btn {
padding: 0.5em 1em;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 500;
text-decoration: none;
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, color 0.2s ease-in-out;
&:hover {
opacity: 0.9;
}
&:active {
opacity: 0.7;
}
&.btn-disabled,
&[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
}
// Variants
.btn-primary {
background-color: #007bff; // Example color
color: white;
border-color: #007bff;
}
.btn-secondary {
background-color: #6c757d; // Example color
color: white;
border-color: #6c757d;
}
.btn-neutral {
background-color: #f8f9fa; // Example color
color: #212529;
border-color: #ced4da;
}
.btn-danger {
background-color: #dc3545; // Example color
color: white;
border-color: #dc3545;
}
// Sizes
.btn-sm {
padding: 0.25em 0.5em;
font-size: 0.875em;
}
.btn-md {
// Default size, styles are in .btn
}
.btn-lg {
padding: 0.6em 1.2em;
font-size: 1.125em;
}
// Icon only
.btn-icon-only {
padding: 0.5em; // Adjust padding for icon-only buttons
// Ensure VIcon fills the space or adjust VIcon size if needed
& .mr-1 { margin-right: 0 !important; } // Remove margin if accidentally applied
& .ml-1 { margin-left: 0 !important; } // Remove margin if accidentally applied
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// Margins for icons next to text (can be refined)
.mr-1 {
margin-right: 0.25em;
}
.ml-1 {
margin-left: 0.25em;
}
</style>

View File

@ -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: '<i :class="`mock-icon icon-${name}`"></i>',
// }));
// 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 = '<div class="custom-header">Custom Header</div>';
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: '<p>Body</p>' } });
expect(wrapper.find('.card-header').exists()).toBe(false);
});
it('renders default slot content in .card-body', () => {
const bodyContent = '<p>Main card content here.</p>';
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 = '<span>Card Footer Text</span>';
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: '<p>Body</p>' } });
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 = '<button>Add Item</button>';
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: '<p>Standard body</p>',
footer: '<span>Standard footer</span>',
},
});
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);
});
});
});

View File

@ -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<typeof VCard> = {
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<typeof VCard>;
export const DefaultWithAllSlots: Story = {
render: (args) => ({
components: { VCard, VButton },
setup() {
return { args };
},
template: `
<VCard :headerTitle="args.headerTitle" :variant="args.variant">
<template #header v-if="args.useCustomHeaderSlot">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>Custom Header Slot</span>
<VButton size="sm" variant="neutral">Action</VButton>
</div>
</template>
<p>This is the main body content of the card. It can contain any HTML or Vue components.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<template #footer v-if="args.useCustomFooterSlot">
<VButton variant="primary">Save Changes</VButton>
<VButton variant="neutral">Cancel</VButton>
</template>
</VCard>
`,
}),
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: `
<VCard :headerTitle="args.headerTitle">
<p>Card body content goes here.</p>
<template #footer>
<p style="font-size: 0.9em; color: #555;">Simple footer text.</p>
</template>
</VCard>
`,
}),
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: `
<VCard>
<p>This card only has body content. No header or footer will be rendered.</p>
<p>It's useful for simple information display.</p>
</VCard>
`,
}),
args: {},
};
export const HeaderAndBody: Story = {
render: (args) => ({
components: { VCard },
setup() { return { args }; },
template: `
<VCard :headerTitle="args.headerTitle">
<p>This card has a header (via prop) and body content, but no footer.</p>
</VCard>
`,
}),
args: {
headerTitle: 'User Profile',
},
};
export const EmptyState: Story = {
render: (args) => ({
components: { VCard, VIcon, VButton }, // VIcon is used internally by VCard
setup() {
return { args };
},
template: `
<VCard
variant="empty-state"
:emptyIcon="args.emptyIcon"
:emptyTitle="args.emptyTitle"
:emptyMessage="args.emptyMessage"
>
<template #empty-actions v-if="args.showEmptyActions">
<VButton variant="primary" @click="() => alert('Add Item Clicked!')">Add New Item</VButton>
<VButton variant="neutral">Learn More</VButton>
</template>
</VCard>
`,
}),
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
},
};

View File

@ -0,0 +1,160 @@
<template>
<div :class="cardClasses">
<template v-if="variant === 'empty-state'">
<div class="card-body empty-state-content">
<VIcon v-if="emptyIcon" :name="emptyIcon" class="empty-state-icon" size="lg" />
<h3 v-if="emptyTitle" class="empty-state-title">{{ emptyTitle }}</h3>
<p v-if="emptyMessage" class="empty-state-message">{{ emptyMessage }}</p>
<div v-if="$slots['empty-actions']" class="empty-state-actions">
<slot name="empty-actions"></slot>
</div>
</div>
</template>
<template v-else>
<div v-if="$slots.header || headerTitle" class="card-header">
<slot name="header">
<h2 v-if="headerTitle" class="card-header-title">{{ headerTitle }}</h2>
</slot>
</div>
<div class="card-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon is in the same directory or globally registered
type CardVariant = 'default' | 'empty-state';
export default defineComponent({
name: 'VCard',
components: {
VIcon,
},
props: {
headerTitle: {
type: String,
default: null,
},
variant: {
type: String as PropType<CardVariant>,
default: 'default',
validator: (value: string) => ['default', 'empty-state'].includes(value),
},
// Empty state specific props
emptyIcon: {
type: String,
default: null,
},
emptyTitle: {
type: String,
default: null,
},
emptyMessage: {
type: String,
default: null,
},
},
setup(props) {
const cardClasses = computed(() => [
'card',
{ 'empty-state-card': props.variant === 'empty-state' },
]);
return {
cardClasses,
};
},
});
</script>
<style lang="scss" scoped>
.card {
background-color: #fff;
border: 1px solid #e0e0e0; // Example border color
border-radius: 0.375rem; // 6px, example
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); // Subtle shadow
display: flex;
flex-direction: column;
}
.card-header {
padding: 1rem 1.25rem; // Example padding
border-bottom: 1px solid #e0e0e0;
background-color: #f8f9fa; // Light background for header
.card-header-title {
margin: 0;
font-size: 1.25rem; // Larger font for header title
font-weight: 500;
}
// If using custom slot, ensure its content is styled appropriately
}
.card-body {
padding: 1.25rem; // Example padding
flex-grow: 1; // Allows body to expand if card has fixed height or content pushes footer
}
.card-footer {
padding: 1rem 1.25rem;
border-top: 1px solid #e0e0e0;
background-color: #f8f9fa; // Light background for footer
display: flex; // Useful for aligning items in the footer (e.g., buttons)
justify-content: flex-end; // Example: align buttons to the right
gap: 0.5rem; // Space between items in footer if multiple
}
// Empty state variant
.empty-state-card {
// Specific overall card styling for empty state if needed
// e.g. it might have a different border or background
// For now, it mainly affects the content layout via .empty-state-content
border-style: dashed; // Example: dashed border for empty state
border-color: #adb5bd;
}
.empty-state-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem 1.5rem; // More padding for empty state
min-height: 200px; // Ensure it has some presence
.empty-state-icon {
// VIcon's size prop is used, but we can add margin or color here
// font-size: 3rem; // If VIcon size prop wasn't sufficient
color: #6c757d; // Muted color for icon (e.g., Gray-600)
margin-bottom: 1rem;
}
.empty-state-title {
font-size: 1.5rem; // Larger title for empty state
font-weight: 500;
color: #343a40; // Darker color for title (e.g., Gray-800)
margin-top: 0;
margin-bottom: 0.5rem;
}
.empty-state-message {
font-size: 1rem;
color: #6c757d; // Muted color for message (e.g., Gray-600)
margin-bottom: 1.5rem;
max-width: 400px; // Constrain message width for readability
}
.empty-state-actions {
display: flex;
gap: 0.75rem; // Space between action buttons
// Buttons inside will be styled by VButton or other button components
}
}
</style>

View File

@ -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');
});
});

View File

@ -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<typeof VCheckbox> = {
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<typeof VCheckbox>;
// 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: '<VCheckbox v-bind="args" :modelValue="storyValue" @update:modelValue="onChange" />',
}),
};
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: `
<VFormField :errorMessage="args.formFieldArgs.errorMessage">
<VCheckbox
v-bind="args.checkboxArgs"
:modelValue="storyValue"
@update:modelValue="onChange"
/>
</VFormField>
`,
}),
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',
},
};

View File

@ -0,0 +1,146 @@
<template>
<label :class="labelClasses" :for="id">
<input
type="checkbox"
:id="id"
:checked="modelValue"
:disabled="disabled"
@change="onChange"
/>
<span class="checkmark"></span>
<span v-if="label" class="checkbox-text-label">{{ label }}</span>
</label>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
export default defineComponent({
name: 'VCheckbox',
props: {
modelValue: {
type: Boolean,
required: true,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
id: {
type: String,
default: () => `vcheckbox-${Math.random().toString(36).substring(2, 9)}`, // Auto-generate ID if not provided
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const labelClasses = computed(() => [
'checkbox-label',
{ 'disabled': props.disabled },
]);
const onChange = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.checked);
};
return {
labelClasses,
onChange,
};
},
});
</script>
<style lang="scss" scoped>
.checkbox-label {
display: inline-flex; // Changed from block to inline-flex for better alignment with other form elements if needed
align-items: center;
cursor: pointer;
position: relative;
user-select: none; // Prevent text selection on click
padding-left: 28px; // Space for the custom checkmark
min-height: 20px; // Ensure consistent height, matches checkmark size + border
font-size: 1rem; // Default font size, can be inherited or customized
// Hide the default browser checkbox
input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
// Custom checkmark
.checkmark {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
height: 20px;
width: 20px;
background-color: #fff; // Default background
border: 1px solid #adb5bd; // Default border (e.g., Gray-400)
border-radius: 0.25rem; // Rounded corners
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
// Checkmark symbol (hidden when not checked)
&:after {
content: "";
position: absolute;
display: none;
left: 6px;
top: 2px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
// When checkbox is checked
input[type="checkbox"]:checked ~ .checkmark {
background-color: #007bff; // Checked background (e.g., Primary color)
border-color: #007bff;
}
input[type="checkbox"]:checked ~ .checkmark:after {
display: block; // Show checkmark symbol
}
// Focus state (accessibility) - style the custom checkmark
input[type="checkbox"]:focus ~ .checkmark {
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); // Focus ring like Bootstrap
}
// Disabled state
&.disabled {
cursor: not-allowed;
opacity: 0.7; // Dim the entire label including text
input[type="checkbox"]:disabled ~ .checkmark {
background-color: #e9ecef; // Disabled background (e.g., Gray-200)
border-color: #ced4da; // Disabled border (e.g., Gray-300)
}
input[type="checkbox"]:disabled:checked ~ .checkmark {
background-color: #7badec; // Lighter primary for disabled checked state
border-color: #7badec;
}
}
.checkbox-text-label {
margin-left: 0.5rem; // Space between checkmark and text label (if checkmark is not absolute or padding-left on root is used)
// With absolute checkmark and padding-left on root, this might not be needed or adjusted.
// Given current setup (padding-left: 28px on root), this provides additional space if label text is present.
// If checkmark was part of the flex flow, this would be more critical.
// Let's adjust to ensure it's always to the right of the 28px padded area.
vertical-align: middle; // Align text with the (conceptual) middle of the checkmark
}
}
</style>

View File

@ -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: '<input id="test-input" type="text" />',
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: '<input type="text" id="my-input" />',
},
});
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: '<input id="user-input" />' }
});
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: '<input />' }
});
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: '<input />' }
});
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: '<input />' }
});
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: `<input id="${inputId}" />` }
});
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: '<input />' }
});
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: '<input />' }
});
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: '<input />' }
});
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: '<input type="password" id="pass" />'
}
});
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);
});
});

View File

@ -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: '<input :id="id" type="text" :placeholder="placeholder" style="border: 1px solid #ccc; padding: 0.5em; border-radius: 4px; width: 100%;" />',
props: ['id', 'placeholder'],
};
const meta: Meta<typeof VFormField> = {
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<typeof VFormField>;
export const WithLabelAndInput: Story = {
render: (args) => ({
components: { VFormField, VInputPlaceholder },
setup() {
return { args };
},
template: `
<VFormField :label="args.label" :forId="args.forId">
<VInputPlaceholder id="nameInput" placeholder="Enter your name" />
</VFormField>
`,
}),
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: `
<VFormField :label="args.label" :forId="args.forId" :errorMessage="args.errorMessage">
<VInputPlaceholder id="emailInput" placeholder="Enter your email" />
</VFormField>
`,
}),
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: `
<VFormField :errorMessage="args.errorMessage">
<VInputPlaceholder id="searchInput" placeholder="Search..." />
</VFormField>
`,
}),
args: {
label: '', // No label
forId: '',
errorMessage: '', // No error
},
};
export const InputWithErrorNoLabel: Story = {
render: (args) => ({
components: { VFormField, VInputPlaceholder },
setup() {
return { args };
},
template: `
<VFormField :errorMessage="args.errorMessage">
<VInputPlaceholder id="passwordInput" type="password" placeholder="Enter password" />
</VFormField>
`,
}),
args: {
label: '',
forId: '',
errorMessage: 'Password is required.',
},
};
export const WithLabelNoErrorNoInputId: Story = {
render: (args) => ({
components: { VFormField, VInputPlaceholder },
setup() {
return { args };
},
template: `
<VFormField :label="args.label" :forId="args.forId">
<!-- Input without an ID, label 'for' will not connect -->
<VInputPlaceholder placeholder="Generic input" />
</VFormField>
`,
}),
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.",
},
},
},
};

View File

@ -0,0 +1,65 @@
<template>
<div class="form-group">
<label v-if="label" :for="forId" class="form-label">{{ label }}</label>
<slot></slot>
<p v-if="errorMessage" class="form-error-message">{{ errorMessage }}</p>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
export default defineComponent({
name: 'VFormField',
props: {
label: {
type: String,
default: null,
},
// 'for' is a reserved keyword in JS, so often component props use 'htmlFor' or similar.
// However, Vue allows 'for' in props directly. Let's stick to 'forId' for clarity to avoid confusion.
forId: {
type: String,
default: null,
},
errorMessage: {
type: String,
default: null,
},
},
setup(props) {
// No specific setup logic needed for this component's current requirements.
// Props are directly used in the template.
return {};
},
});
</script>
<style lang="scss" scoped>
.form-group {
margin-bottom: 1rem; // Spacing between form fields
}
.form-label {
display: block;
margin-bottom: 0.5rem; // Space between label and input
font-weight: 500;
// Add other label styling as needed from design system
// e.g., color: var(--label-color);
}
.form-error-message {
margin-top: 0.25rem; // Space between input and error message
font-size: 0.875em; // Smaller text for error messages
color: #dc3545; // Example error color (Bootstrap's danger color)
// Replace with SCSS variable: var(--danger-color) or similar
// Add other error message styling as needed
}
// Styling for slotted content (inputs, textareas, etc.) will typically
// be handled by those components themselves (e.g., VInput, VTextarea).
// However, you might want to ensure consistent width or display:
// ::v-deep(input), ::v-deep(textarea), ::v-deep(select) {
// width: 100%; // Example to make slotted inputs take full width of form-group
// }
</style>

View File

@ -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 <i> tag or nothing.
// Here, we assume it renders the <i> 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 <i> 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);
});
});

View File

@ -0,0 +1,51 @@
import VIcon from './VIcon.vue';
import type { Meta, StoryObj } from '@storybook/vue3';
const meta: Meta<typeof VIcon> = {
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<typeof VIcon>;
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.',
},
};

View File

@ -0,0 +1,62 @@
<template>
<i :class="iconClasses"></i>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
export default defineComponent({
name: 'VIcon',
props: {
name: {
type: String,
required: true,
},
size: {
type: String,
validator: (value: string) => ['sm', 'lg'].includes(value),
},
},
setup(props) {
const iconClasses = computed(() => {
const classes = ['icon', `icon-${props.name}`];
if (props.size) {
classes.push(`icon-${props.size}`);
}
return classes;
});
return {
iconClasses,
};
},
});
</script>
<style lang="scss" scoped>
// Basic icon styling - will be expanded in a later task
.icon {
display: inline-block;
// Add common icon styles here
}
// Placeholder for actual icon styles (e.g., using a font icon or SVG)
// These will be defined in a separate SCSS file (VIcon.scss)
.icon-alert:before {
content: '⚠️'; // Example, replace with actual icon
}
.icon-search:before {
content: '🔍'; // Example, replace with actual icon
}
.icon-close:before {
content: '❌'; // Example, replace with actual icon
}
.icon-sm {
font-size: 0.8em; // Example size
}
.icon-lg {
font-size: 1.5em; // Example size
}
</style>

View File

@ -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();
});
});

View File

@ -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<typeof VInput> = {
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<typeof VInput>;
// 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: '<VInput v-bind="args" :modelValue="storyValue" @update:modelValue="onInput" />',
}),
};
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: `
<VFormField :label="args.formFieldArgs.label" :forId="args.inputArgs.id" :errorMessage="args.formFieldArgs.errorMessage">
<VInput
v-bind="args.inputArgs"
:modelValue="storyValue"
@update:modelValue="onInput"
/>
</VFormField>
`,
}),
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
},
},
};

View File

@ -0,0 +1,127 @@
<template>
<input
:id="id"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:class="inputClasses"
:aria-invalid="error ? 'true' : null"
@input="onInput"
/>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
// It's good practice to define specific types for props like 'type' if you want to restrict them,
// but for VInput, standard HTML input types are numerous.
// For now, we'll use String and rely on native HTML behavior.
// type InputType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search' | 'date' ; // etc.
export default defineComponent({
name: 'VInput',
props: {
modelValue: {
type: [String, Number] as PropType<string | number>,
required: true,
},
type: {
type: String, // as PropType<InputType> if you define a specific list
default: 'text',
},
placeholder: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
required: {
type: Boolean,
default: false,
},
error: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const inputClasses = computed(() => [
'form-input',
{ 'error': props.error },
]);
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement;
// For number inputs, target.value might still be a string,
// convert if type is number and value is parsable.
// However, v-model.number modifier usually handles this.
// Here, we just emit the raw value. Parent can handle conversion.
emit('update:modelValue', target.value);
};
return {
inputClasses,
onInput,
};
},
});
</script>
<style lang="scss" scoped>
.form-input {
display: block;
width: 100%; // Inputs typically span the full width of their container
padding: 0.5em 0.75em; // Example padding, adjust as per design
font-size: 1rem;
font-family: inherit; // Inherit font from parent
line-height: 1.5;
color: #212529; // Example text color (Bootstrap's default)
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da; // Example border (Bootstrap's default)
border-radius: 0.25rem; // Example border-radius
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
border-color: #80bdff; // Example focus color (Bootstrap's default)
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); // Example focus shadow
}
&::placeholder {
color: #6c757d; // Example placeholder color (Bootstrap's default)
opacity: 1; // Override Firefox's lower default opacity
}
&[disabled],
&[readonly] {
background-color: #e9ecef; // Example disabled background (Bootstrap's default)
opacity: 1; // Ensure text is readable
cursor: not-allowed;
}
// Error state
&.error {
border-color: #dc3545; // Example error color (Bootstrap's danger)
// Add other error state styling, e.g., box-shadow, text color if needed
&:focus {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
}
}
// Specific styling for different input types if needed, e.g., for number inputs
// input[type="number"] {
// // Styles for number inputs, like removing spinners on some browsers
// }
</style>

View File

@ -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: '<VListItem>Item 1</VListItem><VListItem>Item 2</VListItem>',
},
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 <ul> 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('<ul class="item-list"></ul>');
});
it('renders non-VListItem children if passed', () => {
const wrapper = mount(VList, {
slots: {
default: '<li>Raw LI</li><div>Just a div</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');
});
});

View File

@ -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<typeof VList> = {
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<typeof VList>;
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: `
<VList>
<VListItem
v-for="item in items"
:key="item.id"
:completed="item.completed"
:swipable="item.swipable"
:isSwiped="item.isSwiped"
@click="item.swipable ? toggleSwipe(item) : markComplete(item)"
style="border-bottom: 1px solid #eee;"
>
<div style="display: flex; align-items: center; width: 100%;">
<VAvatar v-if="item.avatar" :src="item.avatar" :initials="item.text.substring(0,1)" style="margin-right: 12px;" />
<span style="flex-grow: 1;">{{ item.text }}</span>
<VBadge v-if="item.badgeText" :text="item.badgeText" :variant="item.badgeVariant" style="margin-left: 12px;" />
</div>
<template #swipe-actions-right>
<VButton variant="danger" size="sm" @click.stop="deleteItem(item.id)" style="height: 100%; border-radius:0;">Delete</VButton>
<VButton variant="neutral" size="sm" @click.stop="toggleSwipe(item)" style="height: 100%; border-radius:0;">Cancel</VButton>
</template>
</VListItem>
<VListItem v-if="!items.length">No items in the list.</VListItem>
</VList>
`,
}),
args: {},
};
export const EmptyList: Story = {
render: (args) => ({
components: { VList, VListItem },
setup() {
return { args };
},
template: `
<VList>
<VListItem>The list is currently empty.</VListItem>
</VList>
`,
}),
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: `
<VList>
<VListItem style="border-bottom: 1px solid #eee;">First item</VListItem>
<VListItem style="border-bottom: 1px solid #eee;">Second item</VListItem>
<VListItem>Third item</VListItem>
</VList>
`
}),
args: {}
};

View File

@ -0,0 +1,31 @@
<template>
<ul class="item-list">
<slot></slot>
</ul>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'VList',
// No props defined for VList for now
});
</script>
<style lang="scss" scoped>
.item-list {
list-style: none; // Remove default ul styling
padding: 0;
margin: 0;
// Add any list-wide styling, e.g., borders between items if not handled by VListItem
// For example, if VListItems don't have their own bottom border:
// > ::v-deep(.list-item:not(:last-child)) {
// border-bottom: 1px solid #eee;
// }
// However, it's often better for VListItem to manage its own borders for more flexibility.
background-color: #fff; // Default background for the list area
border-radius: 0.375rem; // Optional: if the list itself should have rounded corners
overflow: hidden; // If list items have rounded corners and list has bg, this prevents bleed
}
</style>

View File

@ -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: '<button><slot/></button>'
// }));
describe('VListItem.vue', () => {
it('renders default slot content in .list-item-content', () => {
const itemContent = '<span>Hello World</span>';
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 = '<button>Delete</button>';
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': '<button>Delete</button>' },
});
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 = '<button>Archive</button>';
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': '<button>Archive</button>' },
});
expect(wrapper.find('.swipe-actions.swipe-actions-left').exists()).toBe(false);
});
it('root element is an <li> by default', () => {
const wrapper = mount(VListItem);
expect(wrapper.element.tagName).toBe('LI');
});
});

View File

@ -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<typeof VListItem> = {
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: '<VList><story/></VList>' })], // 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<typeof VListItem>;
export const Basic: Story = {
render: (args) => ({
components: { VListItem },
setup() { return { args }; },
template: '<VListItem v-bind="args">Basic List Item</VListItem>',
}),
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: '<VListItem :completed="args.completed" :swipable="args.swipable" :isSwiped="args.isSwiped">{{ args.defaultSlotContent }}</VListItem>',
}),
};
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: `
<VListItem
:swipable="args.swipable"
:isSwiped="isSwipedState"
:completed="args.completed"
@click="toggleSwipe"
>
{{ args.defaultSlotContent }}
<template #swipe-actions-right>
<VButton variant="danger" size="sm" @click.stop="() => alert('Delete clicked')" style="height:100%; border-radius:0;">
<VIcon name="close" /> Delete
</VButton>
<VButton variant="neutral" size="sm" @click.stop="toggleSwipe" style="height:100%; border-radius:0;">
Cancel
</VButton>
</template>
<template #swipe-actions-left>
<VButton variant="primary" size="sm" @click.stop="() => alert('Archive clicked')" style="height:100%; border-radius:0;">
<VIcon name="alert" /> Archive
</VButton>
</template>
</VListItem>
`,
}),
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: `
<VListItem :completed="args.completed" :swipable="args.swipable" :isSwiped="args.isSwiped">
<div style="display: flex; align-items: center; width: 100%;">
<VAvatar :src="args.avatarSrc" :initials="args.avatarInitials" style="margin-right: 12px;" />
<div style="flex-grow: 1;">
<div style="font-weight: 500;">{{ args.title }}</div>
<div style="font-size: 0.9em; color: #555;">{{ args.subtitle }}</div>
</div>
<VBadge :text="args.badgeText" :variant="args.badgeVariant" />
</div>
</VListItem>
`,
}),
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',
},
};

View File

@ -0,0 +1,256 @@
<template>
<li :class="itemClasses">
<div v-if="swipable && $slots['swipe-actions-left']" class="swipe-actions swipe-actions-left">
<slot name="swipe-actions-left"></slot>
</div>
<div class="list-item-content-wrapper"> <!-- New wrapper for content + right swipe -->
<div class="list-item-content">
<slot></slot>
</div>
<div v-if="swipable && $slots['swipe-actions-right']" class="swipe-actions swipe-actions-right">
<slot name="swipe-actions-right"></slot>
</div>
</div>
</li>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
export default defineComponent({
name: 'VListItem',
props: {
completed: {
type: Boolean,
default: false,
},
swipable: {
type: Boolean,
default: false,
},
isSwiped: { // This prop controls the visual "swiped" state
type: Boolean,
default: false,
},
},
setup(props) {
const itemClasses = computed(() => [
'list-item',
{
'completed': props.completed,
'is-swiped': props.isSwiped && props.swipable, // Only apply if swipable
'swipable': props.swipable, // Add a general class if item is swipable for base styling
},
]);
return {
itemClasses,
};
},
});
</script>
<style lang="scss" scoped>
.list-item {
position: relative; // For positioning swipe actions absolutely if needed, or for overflow handling
background-color: #fff; // Default item background
// border-bottom: 1px solid #e0e0e0; // Example item separator
// &:last-child {
// border-bottom: none;
// }
display: flex; // Using flex to manage potential left/right swipe areas if they are part of the flow
overflow: hidden; // Crucial for the swipe reveal effect with translate
&.swipable {
// Base styling for swipable items, if any.
// For example, you might want a slightly different cursor or hover effect.
}
}
// This wrapper will be translated to reveal swipe actions
.list-item-content-wrapper {
flex-grow: 1;
display: flex; // To place content and right-swipe actions side-by-side
transition: transform 0.3s ease-out;
// The content itself should fill the space and not be affected by swipe actions' width
// until it's translated.
width: 100%; // Ensures it takes up the full space initially
z-index: 1; // Keep content above swipe actions until swiped
}
.list-item-content {
padding: 0.75rem 1rem; // Example padding
flex-grow: 1; // Content takes available space
// Add other common styling for list item content area
// e.g., text color, font size
background-color: inherit; // Inherit from .list-item, can be overridden
// Useful so that when it slides, it has the right bg
}
.swipe-actions {
display: flex;
align-items: center;
justify-content: center;
// These actions are revealed by translating .list-item-content-wrapper
// Their width will determine how much is revealed.
// Example: fixed width for actions container
// width: 80px; // This would be per side
z-index: 0; // Below content wrapper
background-color: #f0f0f0; // Default background for actions area
}
.swipe-actions-left {
position: absolute; // Take out of flow, position to the left
left: 0;
top: 0;
bottom: 0;
// width: auto; // Determined by content, or set fixed
transform: translateX(-100%); // Initially hidden to the left
transition: transform 0.3s ease-out;
.list-item.is-swiped & { // When swiped to reveal left actions
transform: translateX(0);
}
}
.swipe-actions-right {
// This is now part of the list-item-content-wrapper flex layout
// It's revealed by translating the list-item-content part of the wrapper,
// or by translating the wrapper itself and having this fixed.
// For simplicity, let's assume it has a fixed width and is revealed.
// No, the current structure has list-item-content-wrapper moving.
// So, swipe-actions-right should be fixed at the end of list-item.
position: absolute;
right: 0;
top: 0;
bottom: 0;
// width: auto; // Determined by its content, or set fixed
transform: translateX(100%); // Initially hidden to the right
transition: transform 0.3s ease-out;
// This approach is if .list-item-content is translated.
// If .list-item-content-wrapper is translated, then this needs to be static inside it.
// Let's adjust based on list-item-content-wrapper moving.
// No, the initial thought was:
// <left-actions /> <content-wrapper> <content/> <right-actions/> </content-wrapper>
// If content-wrapper translates left, right actions are revealed.
// If content-wrapper translates right, left actions (if they were outside) are revealed.
// The current HTML structure is:
// <left-actions /> <content-wrapper> <content/> </content-wrapper> <right-actions /> (if right actions are outside wrapper)
// Or:
// <left-actions /> <content-wrapper> <content/> <right-actions/> </content-wrapper> (if right actions are inside wrapper)
// The latter is what I have in the template. So list-item-content-wrapper translates.
// This needs to be outside the list-item-content-wrapper in the flex flow of .list-item
// Or, .list-item-content-wrapper itself is translated.
// Let's assume .list-item-content-wrapper is translated.
// The swipe-actions-left and swipe-actions-right are fixed, and content slides over them.
// This is a common pattern. The current HTML needs slight adjustment for that.
// Re-thinking the template for common swipe:
// <li class="list-item">
// <div class="swipe-actions-left">...</div>
// <div class="list-item-content">...</div> <!-- This is the part that moves -->
// <div class="swipe-actions-right">...</div>
// </li>
// And .list-item-content would get transform: translateX().
// Let's stick to current template and make it work:
// <left-actions/> <wrapper> <content/> <right-actions/> </wrapper>
// If wrapper translates left, it reveals its own right-actions.
// If wrapper translates right, it reveals the list-item's left-actions.
// Reveal right actions by translating the list-item-content-wrapper to the left
.list-item.is-swiped & { // This assumes isSwiped means revealing RIGHT actions.
// Need differentiation if both left/right can be revealed independently.
// For now, isSwiped reveals right.
// This class is on .list-item. The .swipe-actions-right is inside the wrapper.
// So, the wrapper needs to translate.
// No, this is fine. .list-item.is-swiped controls the transform of list-item-content-wrapper.
// Let's assume .list-item-content-wrapper translates left by the width of .swipe-actions-right
// This means .swipe-actions-right needs to have a defined width.
// Example: If .swipe-actions-right is 80px wide:
// .list-item.is-swiped .list-item-content-wrapper { transform: translateX(-80px); }
// And .swipe-actions-right would just sit there.
// This logic should be on .list-item-content-wrapper based on .is-swiped of parent.
}
}
// Adjusting transform on list-item-content-wrapper based on parent .is-swiped
.list-item.is-swiped .list-item-content-wrapper {
// This needs to be dynamic based on which actions are shown and their width.
// For a simple right swipe reveal:
// transform: translateX(-[width of right actions]);
// Example: if right actions are 80px wide. We need JS to measure or fixed CSS.
// For now, let's assume a class like .reveal-right on .list-item sets this.
// If isSwiped just means "right is revealed":
// transform: translateX(-80px); // Placeholder, assumes 80px width of right actions
// This needs to be more robust.
// Let's make .is-swiped simply enable the visibility of the actions,
// and the actions themselves are positioned absolutely or revealed by fixed translation.
// Revised approach: actions are absolutely positioned, content slides.
// This means list-item-content needs to be the one moving, not list-item-content-wrapper.
// The HTML needs to be:
// <li>
// <div class="swipe-actions-left">...</div>
// <div class="list-item-content"> <!-- This is the one that moves -->
// <slot></slot>
// </div>
// <div class="swipe-actions-right">...</div>
// </li>
// I will adjust the template above based on this.
// ... (Template adjusted above - no, I will stick to the current one for now and make it work)
// With current template:
// <li class="list-item">
// <div class="swipe-actions swipe-actions-left">...</div> (absolute, revealed by content-wrapper translating right)
// <div class="list-item-content-wrapper"> (this translates left or right)
// <div class="list-item-content">...</div>
// <div class="swipe-actions swipe-actions-right">...</div> (flex child of wrapper, revealed when wrapper translates left)
// </div>
// </li>
// If .list-item.is-swiped means "right actions revealed":
// .list-item.is-swiped .list-item-content-wrapper { transform: translateX(-[width of .swipe-actions-right]); }
// If .list-item.is-left-swiped means "left actions revealed":
// .list-item.is-left-swiped .list-item-content-wrapper { transform: translateX([width of .swipe-actions-left]); }
// The `isSwiped` prop is boolean, so it can only mean one direction. Assume it's for right.
// To make this work, .swipe-actions-right needs a defined width.
// Let's assume actions have a button that defines their width.
// This CSS is a placeholder for the actual swipe mechanics.
// For Storybook, we just want to show/hide based on `isSwiped`.
// Let's simplify: .is-swiped shows right actions by setting transform on wrapper.
// The actual width needs to be handled by the content of swipe-actions-right.
// This is hard to do purely in CSS without knowing width.
// A common trick is to set right: 0 on actions and let content slide.
// Simplified for story:
// We'll have .swipe-actions-right just appear when .is-swiped.
// This won't look like a swipe, but will show the slot.
// A true swipe needs JS to measure or fixed widths.
// Let's go with a fixed transform for now for demo purposes.
.list-item.is-swiped .list-item-content-wrapper {
transform: translateX(-80px); // Assumes right actions are 80px.
}
// And left actions (if any)
.list-item.is-left-swiped .list-item-content-wrapper { // Hypothetical class
transform: translateX(80px); // Assumes left actions are 80px.
}
// Since `isSwiped` is boolean, it can only control one state.
// Let's assume `isSwiped` means "the right actions are visible".
.list-item.completed {
.list-item-content {
// Example: strike-through text or different background
// color: #adb5bd; // Muted text color
// text-decoration: line-through;
background-color: #f0f8ff; // Light blue background for completed
}
// You might want to disable swipe on completed items or style them differently
&.swipable .list-item-content {
// Specific style for swipable AND completed
}
}
</style>

View File

@ -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: '<i :class="`mock-icon icon-${name}`"></i>',
}));
// 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 <Teleport> 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 = '<div class="custom-header">Custom Header</div>';
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 = '<p>Modal body content.</p>';
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 = '<button>OK</button>';
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: '<p>Description</p>' },
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: '<p>Description</p>' },
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();
});
});

View File

@ -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<typeof VModal> = {
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<typeof VModal>;
// 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: `
<div>
<VButton @click="isModalOpen = true">Open Modal</VButton>
<VModal
:modelValue="isModalOpen"
@update:modelValue="onUpdateModelValue"
:title="title"
:hideCloseButton="hideCloseButton"
:persistent="persistent"
:size="size"
:idBase="idBase"
@opened="() => $emit('opened')"
@closed="() => $emit('closed')"
@close="() => $emit('close')"
>
<template #header v-if="args.customHeaderSlot">
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h3 style="margin:0;"><em>Custom Header Slot!</em></h3>
<VButton v-if="!hideCloseButton" @click="isModalOpen = false" size="sm" variant="neutral">Close from slot</VButton>
</div>
</template>
<template #default>
<p v-if="args.bodyContent">{{ args.bodyContent }}</p>
<slot name="storyDefaultContent"></slot>
</template>
<template #footer v-if="args.showFooter !== false">
<slot name="storyFooterContent">
<VButton variant="neutral" @click="isModalOpen = false">Cancel</VButton>
<VButton variant="primary" @click="isModalOpen = false">Submit</VButton>
</slot>
</template>
</VModal>
</div>
`,
}),
};
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: `
<div>
<VButton @click="isModalOpen = true">Open Form Modal</VButton>
<VModal
:modelValue="isModalOpen"
@update:modelValue="onUpdateModelValue"
:title="title"
:size="size"
>
<VFormField label="Username" forId="modalUser">
<VInput id="modalUser" v-model="username" placeholder="Enter username" />
</VFormField>
<VFormField label="Password" forId="modalPass">
<VInput id="modalPass" type="password" v-model="password" placeholder="Enter password" />
</VFormField>
<template #footer>
<VButton variant="neutral" @click="isModalOpen = false">Cancel</VButton>
<VButton variant="primary" @click="() => { alert('Submitted: ' + username + ' / ' + password); isModalOpen = false; }">Log In</VButton>
</template>
</VModal>
</div>
`,
}),
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: `
<div>
<VButton @click="isModalOpen = true">Open Modal with Confirmation</VButton>
<VModal
:modelValue="isModalOpen"
@update:modelValue="handleModalUpdate"
:title="title"
:persistent="true"
:hideCloseButton="false"
>
<p>Try to close this modal with text in the input field.</p>
<VInput v-model="textInput" placeholder="Type something here" />
<p v-if="textInput === ''" style="color: green;">No unsaved changes. Modal will close normally.</p>
<p v-else style="color: orange;">Unsaved changes detected!</p>
<template #footer>
<VButton variant="neutral" @click="requestClose">Attempt Close</VButton>
<VButton variant="primary" @click="() => { textInput = ''; alert('Changes saved (simulated)'); }">Save Changes</VButton>
</template>
</VModal>
</div>
`,
}),
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
},
};

View File

@ -0,0 +1,245 @@
<template>
<Teleport to="body">
<Transition
name="modal-fade"
@after-enter="onOpened"
@after-leave="onClosed"
>
<div
v-if="modelValue"
class="modal-backdrop"
@click="handleBackdropClick"
>
<div
class="modal-container"
:class="['modal-container-' + size, { 'open': modelValue }]"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="bodyId"
@click.stop
>
<div v-if="$slots.header || title || !hideCloseButton" class="modal-header">
<slot name="header">
<h3 v-if="title" :id="titleId" class="modal-title">{{ title }}</h3>
<button
v-if="!hideCloseButton"
type="button"
class="close-button"
@click="closeModal"
aria-label="Close modal"
>
<VIcon name="close" />
</button>
</slot>
</div>
<div class="modal-body" :id="bodyId">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, watch, onMounted, onBeforeUnmount, ref } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon is available
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
title: {
type: String,
default: null,
},
hideCloseButton: {
type: Boolean,
default: false,
},
persistent: {
type: Boolean,
default: false,
},
size: {
type: String, // 'sm', 'md', 'lg'
default: 'md',
validator: (value: string) => ['sm', 'md', 'lg'].includes(value),
},
idBase: {
type: String,
default: null,
},
});
const emit = defineEmits(['update:modelValue', 'close', 'opened', 'closed']);
const uniqueComponentId = ref(`modal-${Math.random().toString(36).substring(2, 9)}`);
const titleId = computed(() => props.idBase ? `${props.idBase}-title` : `${uniqueComponentId.value}-title`);
const bodyId = computed(() => props.idBase ? `${props.idBase}-description` : `${uniqueComponentId.value}-description`);
const closeModal = () => {
emit('update:modelValue', false);
emit('close');
};
const handleBackdropClick = () => {
if (!props.persistent) {
closeModal();
}
};
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.modelValue) {
closeModal();
}
};
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
document.body.classList.add('modal-open');
document.addEventListener('keydown', handleEscKey);
} else {
document.body.classList.remove('modal-open');
document.removeEventListener('keydown', handleEscKey);
}
});
// Cleanup listener if component is unmounted while modal is open
onBeforeUnmount(() => {
if (props.modelValue) {
document.body.classList.remove('modal-open');
document.removeEventListener('keydown', handleEscKey);
}
});
const onOpened = () => {
emit('opened');
};
const onClosed = () => {
emit('closed');
};
</script>
<style lang="scss" scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050; // Ensure it's above most other content
}
.modal-container {
background-color: #fff;
border-radius: 0.375rem; // 6px
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
max-height: 90vh; // Prevent modal from being too tall
overflow: hidden; // Needed for children with overflow (e.g. scrollable body)
// Default size (md)
width: 500px; // Example, adjust as needed
max-width: 90%;
&.modal-container-sm {
width: 300px;
}
&.modal-container-lg {
width: 800px;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e0e0e0; // Example border
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 500;
}
.close-button {
background: transparent;
border: none;
font-size: 1.5rem; // Make VIcon larger if it inherits font-size
padding: 0.25rem;
margin: -0.25rem; // Adjust for padding to align visual edge
line-height: 1;
cursor: pointer;
color: #6c757d; // Muted color
&:hover {
color: #343a40;
}
// VIcon specific styling if needed, e.g., for stroke width or size
// ::v-deep(.icon) { font-size: 1.2em; }
}
}
.modal-body {
padding: 1.25rem;
overflow-y: auto; // Scrollable body if content exceeds max-height
flex-grow: 1; // Allow body to take available space
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end; // Common: buttons to the right
padding: 1rem 1.25rem;
border-top: 1px solid #e0e0e0;
gap: 0.5rem; // Space between footer items (buttons)
}
// Transitions
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-active .modal-container,
.modal-fade-leave-active .modal-container {
transition: transform 0.3s ease-out; // Slightly different timing for container
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .modal-container,
.modal-fade-leave-to .modal-container {
transform: translateY(-50px) scale(0.95); // Example: slide down and scale
}
// This class is applied to <body>
// ::v-global(body.modal-open) {
// overflow: hidden;
// }
// Note: ::v-global is not standard. This is typically handled in main CSS or via JS on body.
// The JS part `document.body.classList.add('modal-open')` is already there.
// The style for `body.modal-open` should be in a global stylesheet.
// For demo purposes, if it were here (which it shouldn't be):
// :global(body.modal-open) {
// overflow: hidden;
// }
</style>

View File

@ -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);
});
});

View File

@ -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<typeof VProgressBar> = {
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: '<div style="width: 300px; padding: 20px;"><story/></div>' })],
};
export default meta;
type Story = StoryObj<typeof VProgressBar>;
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<NodeJS.Timeout | null>(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: `
<div>
<VProgressBar
:value="currentValue"
:max="max"
:showText="showText"
:striped="striped"
:label="label"
:valueText="valueText"
/>
<button @click="startProgress" style="margin-top: 10px;">Start/Restart Progress</button>
</div>
`,
}),
args: {
value: 10, // Initial value for the ref
max: 100,
showText: true,
striped: true,
label: 'Dynamic Progress',
},
};

View File

@ -0,0 +1,125 @@
<template>
<div
class="progress-container"
role="progressbar"
:aria-valuenow="percentage"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="label || 'Progress indicator'"
>
<div
class="progress-bar"
:style="{ width: percentage + '%' }"
:class="{ 'striped': striped }"
>
<span v-if="showText" class="progress-text">
{{ displayText }}
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps({
value: {
type: Number,
required: true,
validator: (val: number) => !isNaN(val), // Basic check for valid number
},
max: {
type: Number,
default: 100,
validator: (val: number) => val > 0 && !isNaN(val),
},
showText: {
type: Boolean,
default: true,
},
striped: {
type: Boolean,
default: true,
},
label: {
type: String,
default: null, // Default aria-label is set in template if this is null
},
valueText: { // Custom text to display instead of percentage
type: String,
default: null,
},
});
const percentage = computed(() => {
if (props.max <= 0) return 0; // Avoid division by zero or negative max
const calculated = (props.value / props.max) * 100;
return Math.max(0, Math.min(100, calculated)); // Clamp between 0 and 100
});
const displayText = computed(() => {
if (props.valueText !== null) {
return props.valueText;
}
// You might want to adjust decimal places based on precision needed
return `${percentage.value.toFixed(0)}%`;
});
</script>
<style lang="scss" scoped>
// Assuming --progress-texture is defined in valerie-ui.scss or globally
// For example:
// :root {
// --progress-texture: repeating-linear-gradient(
// 45deg,
// rgba(255, 255, 255, 0.15),
// rgba(255, 255, 255, 0.15) 10px,
// transparent 10px,
// transparent 20px
// );
// --progress-bar-bg: #007bff; // Example primary color
// --progress-bar-text-color: #fff;
// --progress-container-bg: #e9ecef;
// }
.progress-container {
width: 100%;
height: 1.25rem; // Default height, adjust as needed
background-color: var(--progress-container-bg, #e9ecef); // Fallback color
border-radius: 0.25rem; // Rounded corners for the container
overflow: hidden; // Ensure progress-bar respects container's border-radius
position: relative; // For positioning text if needed outside the bar
}
.progress-bar {
height: 100%;
background-color: var(--progress-bar-bg, #007bff); // Fallback color
display: flex;
align-items: center;
justify-content: center; // Center text if it's inside the bar
transition: width 0.3s ease-out; // Smooth transition for width changes
color: var(--progress-bar-text-color, #fff); // Text color for text inside the bar
font-size: 0.75rem; // Smaller font for progress text
line-height: 1; // Ensure text is vertically centered
&.striped {
// The variable --progress-texture should be defined in valerie-ui.scss
// or a global style sheet.
// Example: repeating-linear-gradient(45deg, rgba(255,255,255,.15), rgba(255,255,255,.15) 10px, transparent 10px, transparent 20px)
background-image: var(--progress-texture);
background-size: 28.28px 28.28px; // Adjust size for desired stripe density (sqrt(20^2+20^2)) if texture is 20px based
// Or simply use a fixed size like 40px 40px if the gradient is designed for that
}
}
.progress-text {
// Styling for the text. If it's always centered by .progress-bar,
// specific positioning might not be needed.
// Consider contrast, especially if bar width is small.
// One option is to have text outside the bar if it doesn't fit or for contrast.
// For now, it's centered within the bar.
white-space: nowrap;
padding: 0 0.25rem; // Small padding if text is very close to edges
}
</style>

View File

@ -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');
});
});

View File

@ -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<typeof VRadio> = {
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<typeof VRadio>;
// 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: '<VRadio v-bind="args" :modelValue="storyValue" @update:modelValue="onChange" />',
}),
};
// 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: `
<div>
<VRadio
v-for="option in args.options"
:key="option.value"
:id="'radio-' + args.name + '-' + option.value"
:name="args.name"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
v-model="selectedValue"
/>
<p style="margin-top: 10px;">Selected: {{ selectedValue }}</p>
</div>
`,
}),
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: `
<VFormField :label="args.formFieldArgs.label" :errorMessage="args.formFieldArgs.errorMessage">
<div role="radiogroup" :aria-labelledby="args.formFieldArgs.label ? args.formFieldArgs.labelId : undefined">
<VRadio
v-for="option in args.options"
:key="option.value"
:id="'radio-ff-' + args.name + '-' + option.value"
:name="args.name"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
v-model="selectedValue"
/>
</div>
<p v-if="selectedValue" style="margin-top: 10px;">Selected: {{ selectedValue }}</p>
</VFormField>
`,
}),
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.' },
},
},
};

View File

@ -0,0 +1,165 @@
<template>
<label :class="labelClasses" :for="effectiveId">
<input
type="radio"
:id="effectiveId"
:name="name"
:value="value"
:checked="isChecked"
:disabled="disabled"
@change="onChange"
/>
<span class="checkmark radio-mark"></span>
<span v-if="label" class="radio-text-label">{{ label }}</span>
</label>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
export default defineComponent({
name: 'VRadio',
props: {
modelValue: {
type: [String, Number] as PropType<string | number | null>, // Allow null for when nothing is selected
required: true,
},
value: {
type: [String, Number] as PropType<string | number>,
required: true,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
name: {
type: String,
required: true,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const effectiveId = computed(() => {
return props.id || `vradio-${props.name}-${props.value}`;
});
const labelClasses = computed(() => [
'radio-label',
{ 'disabled': props.disabled },
{ 'checked': isChecked.value }, // For potential styling of the label itself when checked
]);
const isChecked = computed(() => {
return props.modelValue === props.value;
});
const onChange = () => {
if (!props.disabled) {
emit('update:modelValue', props.value);
}
};
return {
effectiveId,
labelClasses,
isChecked,
onChange,
};
},
});
</script>
<style lang="scss" scoped>
// Styles are very similar to VCheckbox, with adjustments for radio appearance (circle)
.radio-label {
display: inline-flex;
align-items: center;
cursor: pointer;
position: relative;
user-select: none;
padding-left: 28px; // Space for the custom radio mark
min-height: 20px;
font-size: 1rem;
margin-right: 10px; // Spacing between radio buttons in a group
input[type="radio"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
height: 20px;
width: 20px;
background-color: #fff;
border: 1px solid #adb5bd;
border-radius: 50%; // Makes it a circle for radio
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
// Radio mark's inner dot (hidden when not checked)
&.radio-mark:after {
content: "";
position: absolute;
display: none;
top: 50%;
left: 50%;
width: 10px; // Size of the inner dot
height: 10px;
border-radius: 50%;
background: white;
transform: translate(-50%, -50%);
}
}
input[type="radio"]:checked ~ .checkmark {
background-color: #007bff;
border-color: #007bff;
}
input[type="radio"]:checked ~ .checkmark.radio-mark:after {
display: block;
}
input[type="radio"]:focus ~ .checkmark {
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
&.disabled {
cursor: not-allowed;
opacity: 0.7;
input[type="radio"]:disabled ~ .checkmark {
background-color: #e9ecef;
border-color: #ced4da;
}
input[type="radio"]:disabled:checked ~ .checkmark {
background-color: #7badec; // Lighter primary for disabled checked
border-color: #7badec;
}
input[type="radio"]:disabled:checked ~ .checkmark.radio-mark:after {
background: #e9ecef; // Match disabled background or a lighter contrast
}
}
.radio-text-label {
// margin-left: 0.5rem; // Similar to checkbox, handled by padding-left on root
vertical-align: middle;
}
}
</style>

View File

@ -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');
});
});

View File

@ -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<typeof VSelect> = {
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<typeof VSelect>;
// 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: '<VSelect v-bind="args" :modelValue="storyValue" @update:modelValue="onChange" />',
}),
};
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: `
<VFormField :label="args.formFieldArgs.label" :forId="args.selectArgs.id" :errorMessage="args.formFieldArgs.errorMessage">
<VSelect
v-bind="args.selectArgs"
:modelValue="storyValue"
@update:modelValue="onChange"
/>
</VFormField>
`,
}),
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,
},
},
};

View File

@ -0,0 +1,158 @@
<template>
<select
:id="id"
:value="modelValue"
:disabled="disabled"
:required="required"
:class="selectClasses"
:aria-invalid="error ? 'true' : null"
@change="onChange"
>
<option v-if="placeholder" value="" disabled :selected="!modelValue">
{{ placeholder }}
</option>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
interface SelectOption {
value: string | number; // Or any, but string/number are most common for select values
label: string;
disabled?: boolean;
}
export default defineComponent({
name: 'VSelect',
props: {
modelValue: {
type: [String, Number] as PropType<string | number>,
required: true,
},
options: {
type: Array as PropType<SelectOption[]>,
required: true,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
required: {
type: Boolean,
default: false,
},
error: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
placeholder: {
type: String,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const selectClasses = computed(() => [
'form-input', // Re-use .form-input styles
'select', // Specific class for select styling (e.g., dropdown arrow)
{ 'error': props.error },
]);
const onChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
// Attempt to convert value to number if original option value was a number
// This helps v-model work more intuitively with numeric values
const selectedOption = props.options.find(opt => String(opt.value) === target.value);
let valueToEmit: string | number = target.value;
if (selectedOption && typeof selectedOption.value === 'number') {
valueToEmit = parseFloat(target.value);
}
emit('update:modelValue', valueToEmit);
};
return {
selectClasses,
onChange,
};
},
});
</script>
<style lang="scss" scoped>
// Assume .form-input styles are available (globally or imported)
// For brevity, these are not repeated here but were defined in VInput/VTextarea.
// If they are not globally available, they should be added or imported.
.form-input {
display: block;
width: 100%;
padding: 0.5em 0.75em; // Adjust padding for select, esp. right for arrow
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
color: #212529;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
&[disabled] {
background-color: #e9ecef;
opacity: 1;
cursor: not-allowed;
}
&.error {
border-color: #dc3545;
&:focus {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
}
}
.select {
// Select-specific styling
appearance: none; // Remove default system appearance
// Custom dropdown arrow (often a ::after pseudo-element or background image)
// Example using background SVG:
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 16px 12px;
padding-right: 2.5rem; // Ensure space for the arrow
// For placeholder option (disabled, selected)
&:invalid, option[value=""][disabled] {
color: #6c757d; // Placeholder text color
}
// Ensure that when a real value is selected, the color is the normal text color
& option {
color: #212529; // Or your default text color for options
}
// Fix for Firefox showing a lower opacity on disabled options
& option:disabled {
color: #adb5bd; // A lighter color for disabled options, but still readable
}
}
</style>

View File

@ -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');
});
});

View File

@ -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<typeof VTextarea> = {
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<typeof VTextarea>;
// 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: '<VTextarea v-bind="args" :modelValue="storyValue" @update:modelValue="onInput" />',
}),
};
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: `
<VFormField :label="args.formFieldArgs.label" :forId="args.textareaArgs.id" :errorMessage="args.formFieldArgs.errorMessage">
<VTextarea
v-bind="args.textareaArgs"
:modelValue="storyValue"
@update:modelValue="onInput"
/>
</VFormField>
`,
}),
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
},
},
};

View File

@ -0,0 +1,139 @@
<template>
<textarea
:id="id"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:rows="rows"
:class="textareaClasses"
:aria-invalid="error ? 'true' : null"
@input="onInput"
></textarea>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
export default defineComponent({
name: 'VTextarea',
props: {
modelValue: {
type: String,
required: true,
},
placeholder: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
required: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 3,
},
error: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const textareaClasses = computed(() => [
'form-input', // Re-use .form-input styles from VInput if they are generic enough
'textarea', // Specific class for textarea if needed for overrides or additions
{ 'error': props.error },
]);
const onInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement;
emit('update:modelValue', target.value);
};
return {
textareaClasses,
onInput,
};
},
});
</script>
<style lang="scss" scoped>
// Assuming .form-input is defined globally or imported, providing base styling.
// If VInput.vue's <style> is not scoped, .form-input might be available.
// If it is scoped, or you want VTextarea to be independent, redefine or import.
// For this example, let's assume .form-input styles from VInput might apply if global,
// or we can duplicate/abstract them.
// Minimal re-definition or import of .form-input (if not globally available)
// If VInput.scss is structured to be importable (e.g. using @use or if not scoped):
// @import 'VInput.scss'; // (path dependent) - this won't work directly with scoped SFC styles normally
// Let's add some basic .form-input like styles here for completeness,
// assuming they are not inherited or globally available from VInput.vue's styles.
// Ideally, these would be part of a shared SCSS utility file.
.form-input {
display: block;
width: 100%;
padding: 0.5em 0.75em;
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
color: #212529;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
&::placeholder {
color: #6c757d;
opacity: 1;
}
&[disabled],
&[readonly] { // readonly is not a prop here, but good for general form-input style
background-color: #e9ecef;
opacity: 1;
cursor: not-allowed;
}
&.error {
border-color: #dc3545;
&:focus {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
}
}
// Textarea specific styles
.textarea {
// Override line-height if needed, or ensure it works well with multi-line text.
// line-height: 1.5; // Usually inherited correctly from .form-input
// May add min-height or resize behavior if desired:
// resize: vertical; // Allow vertical resize, disable horizontal
min-height: calc(1.5em * var(--v-textarea-rows, 3) + 1em + 2px); // Approx based on rows, padding, border
}
// CSS variable for rows to potentially influence height if needed by .textarea class
// This is an alternative way to use props.rows in CSS if you need more complex calculations.
// For direct attribute binding like :rows="rows", this is not strictly necessary.
// :style="{ '--v-textarea-rows': rows }" could be bound to the textarea element.
</style>

View File

@ -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);
});
});

View File

@ -0,0 +1,138 @@
import VToggleSwitch from './VToggleSwitch.vue';
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref, watch } from 'vue';
const meta: Meta<typeof VToggleSwitch> = {
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<typeof VToggleSwitch>;
// 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: `
<div style="display: flex; align-items: center; gap: 10px;">
<VToggleSwitch
:modelValue="switchState"
@update:modelValue="onUpdateModelValue"
:disabled="disabled"
:id="id"
:label="label"
:onText="onText"
:offText="offText"
/>
<span>Current state: {{ switchState ? 'ON' : 'OFF' }}</span>
</div>
`,
}),
};
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.' },
},
},
};

View File

@ -0,0 +1,184 @@
<template>
<div class="switch-container" :class="{ 'disabled': disabled }">
<input
type="checkbox"
role="switch"
:checked="modelValue"
:disabled="disabled"
:id="componentId"
@change="handleChange"
class="sr-only-input"
/>
<label :for="componentId" class="switch">
<span class="sr-only">{{ label || 'Toggle Switch' }}</span>
<!-- The visual track and thumb are typically created via ::before and ::after on .switch (the label) -->
<!-- Text like onText/offText could be positioned absolutely within .switch if design requires -->
<span v-if="onText && modelValue" class="switch-text switch-text-on">{{ onText }}</span>
<span v-if="offText && !modelValue" class="switch-text switch-text-off">{{ offText }}</span>
</label>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
label: { // For accessibility, visually hidden
type: String,
default: 'Toggle Switch', // Default accessible name if not provided
},
onText: { // Optional text for 'on' state
type: String,
default: null,
},
offText: { // Optional text for 'off' state
type: String,
default: null,
},
});
const emit = defineEmits(['update:modelValue']);
const componentId = computed(() => {
return props.id || `v-toggle-switch-${Math.random().toString(36).substring(2, 9)}`;
});
const handleChange = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.checked);
};
</script>
<style lang="scss" scoped>
// Base styles should align with valerie-ui.scss's .switch definition
// Assuming .sr-only is globally defined (position: absolute; width: 1px; height: 1px; ...)
// If not, it needs to be defined here or imported.
.sr-only-input { // Class for the actual input to be hidden
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.sr-only { // For the accessible label text inside the visual label
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.switch-container {
display: inline-flex; // Or block, depending on desired layout flow
align-items: center;
position: relative; // For positioning text if needed
&.disabled {
opacity: 0.7;
cursor: not-allowed;
.switch {
cursor: not-allowed;
}
}
}
.switch {
// These styles are from the provided valerie-ui.scss
// They create the visual appearance of the switch.
position: relative;
display: inline-block;
width: 36px; // var(--switch-width, 36px)
height: 20px; // var(--switch-height, 20px)
background-color: var(--switch-bg-off, #adb5bd); // Off state background
border-radius: 20px; // var(--switch-height, 20px) / 2 for pill shape
cursor: pointer;
transition: background-color 0.2s ease-in-out;
// The thumb (circle)
&::before {
content: "";
position: absolute;
top: 2px; // var(--switch-thumb-offset, 2px)
left: 2px; // var(--switch-thumb-offset, 2px)
width: 16px; // var(--switch-thumb-size, 16px) -> height - 2*offset
height: 16px; // var(--switch-thumb-size, 16px)
background-color: white;
border-radius: 50%;
transition: transform 0.2s ease-in-out;
}
}
// Styling based on the hidden input's state using sibling selector (+)
// This is a common and effective pattern for custom form controls.
.sr-only-input:checked + .switch {
background-color: var(--switch-bg-on, #007bff); // On state background (e.g., primary color)
}
.sr-only-input:checked + .switch::before {
transform: translateX(16px); // var(--switch-width) - var(--switch-thumb-size) - 2 * var(--switch-thumb-offset)
// 36px - 16px - 2*2px = 16px
}
// Focus state for accessibility (applied to the label acting as switch)
.sr-only-input:focus-visible + .switch {
outline: 2px solid var(--switch-focus-ring-color, #007bff);
outline-offset: 2px;
// Or use box-shadow for a softer focus ring:
// box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.35);
}
// Optional: Styles for onText/offText if they are part of the design
// This is a basic example; exact positioning would depend on desired look.
.switch-text {
position: absolute;
font-size: 0.7rem;
font-weight: 500;
top: 50%;
transform: translateY(-50%);
user-select: none;
color: white; // Assuming text is on the colored part of the switch
}
.switch-text-on {
// Example: position onText to the left of the thumb when 'on'
left: 6px;
// visibility: hidden; // Shown by input:checked + .switch .switch-text-on
}
.switch-text-off {
// Example: position offText to the right of the thumb when 'off'
right: 6px;
// visibility: visible;
}
// Show/hide text based on state
// This is a simple way; could also use v-if/v-else in template if preferred.
// .sr-only-input:checked + .switch .switch-text-on { visibility: visible; }
// .sr-only-input:not(:checked) + .switch .switch-text-off { visibility: visible; }
// .sr-only-input:checked + .switch .switch-text-off { visibility: hidden; }
// .sr-only-input:not(:checked) + .switch .switch-text-on { visibility: hidden; }
// The v-if in the template is more Vue-idiomatic and cleaner for this.
</style>

View File

@ -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<typeof VTabs> = {
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<typeof VTabs>;
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: `
<VTabs v-model="currentTab">
<VTabList aria-label="User Account Tabs">
<VTab id="profile" title="Profile" />
<VTab id="settings" title="Settings" />
<VTab id="billing" title="Billing" />
<VTab id="disabled" title="Disabled" :disabled="true" />
</VTabList>
<VTabPanels>
<VTabPanel id="profile">
<p><strong>Profile Tab Content:</strong> Information about the user.</p>
<input type="text" placeholder="User name" />
</VTabPanel>
<VTabPanel id="settings">
<p><strong>Settings Tab Content:</strong> Configuration options.</p>
<label><input type="checkbox" /> Enable notifications</label>
</VTabPanel>
<VTabPanel id="billing">
<p><strong>Billing Tab Content:</strong> Payment methods and history.</p>
<VButton variant="primary">Add Payment Method</VButton>
</VTabPanel>
<VTabPanel id="disabled">
<p>This panel should not be reachable if the tab is truly disabled.</p>
</VTabPanel>
</VTabPanels>
</VTabs>
`,
}),
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: `
<VTabs v-model="currentTab">
<VTabList aria-label="Notification Tabs">
<VTab id="alerts">
<!-- Using VIcon as an example, ensure it's available or replace -->
<span style="color: red; margin-right: 4px;">🔔</span> Alerts
</VTab>
<VTab id="messages">
Messages <span style="background: #007bff; color: white; border-radius: 10px; padding: 2px 6px; font-size: 0.8em; margin-left: 5px;">3</span>
</VTab>
</VTabList>
<VTabPanels>
<VTabPanel id="alerts">
<p>Content for Alerts. Example of custom content in VTab.</p>
</VTabPanel>
<VTabPanel id="messages">
<p>Content for Messages. Also has custom content in its VTab.</p>
<ul>
<li>Message 1</li>
<li>Message 2</li>
<li>Message 3</li>
</ul>
</VTabPanel>
</VTabPanels>
</VTabs>
`,
}),
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: `
<div>
<VTabs v-model="currentTab">
<VTabList aria-label="Interactive Tabs">
<VTab v-for="tabId in availableTabs" :key="tabId" :id="tabId" :title="tabId.charAt(0).toUpperCase() + tabId.slice(1) + ' Tab'" />
</VTabList>
<VTabPanels>
<VTabPanel v-for="tabId in availableTabs" :key="tabId" :id="tabId">
<p>Content for <strong>{{ tabId }}</strong> tab.</p>
</VTabPanel>
</VTabPanels>
</VTabs>
<div style="margin-top: 20px;">
<p>Current active tab (v-model): {{ currentTab || 'None' }}</p>
<VButton @click="selectNextTab">Select Next Tab Programmatically</VButton>
<p><em>Note: Storybook control for 'modelValue' can also change the tab.</em></p>
</div>
</div>
`,
}),
args: {
modelValue: 'first', // Initial value for the v-model
},
};
export const EmptyTabs: Story = {
render: (args) => ({
components: { VTabs, VTabList, VTabPanels },
setup() { return { args }; },
template: `
<VTabs :modelValue="args.modelValue">
<VTabList aria-label="Empty Tab List"></VTabList>
<VTabPanels>
<!-- No VTabPanel components -->
</VTabPanels>
</VTabs>
`,
}),
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: `
<VTabs v-model="currentTab">
<VTabList aria-label="Single Tab Example">
<VTab id="single" title="The Only Tab" />
</VTabList>
<VTabPanels>
<VTabPanel id="single">
<p>This is the content of the only tab panel.</p>
<p>The `VTabs` `onMounted` logic should select this tab by default if no other tab is specified via `modelValue` or `initialTab`.</p>
</VTabPanel>
</VTabPanels>
</VTabs>
`,
}),
args: {
// modelValue: 'single', // Let default selection logic work
},
};

View File

@ -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 = '<i>Custom Tab Content</i>';
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();
});
});

View File

@ -0,0 +1,99 @@
<template>
<button
role="tab"
:id="'tab-' + id"
:aria-selected="isActive.toString()"
:aria-controls="ariaControls"
:disabled="disabled"
:tabindex="isActive ? 0 : -1"
@click="handleClick"
class="tab-item"
:class="{ 'active': isActive, 'disabled': disabled }"
>
<slot>{{ title }}</slot>
</button>
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import type { TabId, TabsContext } from './types';
import { TabsProviderKey } from './types';
const props = defineProps<{
id: TabId;
title?: string;
disabled?: boolean;
}>();
defineOptions({
name: 'VTab',
});
const tabsContext = inject<TabsContext>(TabsProviderKey);
if (!tabsContext) {
throw new Error('VTab must be used within a VTabs component.');
}
const { activeTabId, selectTab } = tabsContext;
const isActive = computed(() => activeTabId.value === props.id);
const ariaControls = computed(() => `panel-${props.id}`);
const handleClick = () => {
if (!props.disabled) {
selectTab(props.id);
}
};
</script>
<style lang="scss" scoped>
.tab-item {
padding: 0.75rem 1.25rem; // Example padding
margin-bottom: -1px; // Overlap with tab-list border for active state
border: 1px solid transparent;
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
cursor: pointer;
background-color: transparent;
color: #007bff; // Default tab text color (link-like)
font-size: 1rem;
font-weight: 500;
text-align: center;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
&:hover:not(.disabled):not(.active) {
border-color: #e9ecef #e9ecef #dee2e6; // Light border on hover
background-color: #f8f9fa; // Light background on hover
color: #0056b3;
}
&.active { // Or use [aria-selected="true"]
color: #495057; // Active tab text color
background-color: #fff; // Active tab background (same as panel usually)
border-color: #dee2e6 #dee2e6 #fff; // Border connects with panel, bottom border transparent
}
&.disabled { // Or use [disabled]
color: #6c757d; // Disabled text color
cursor: not-allowed;
background-color: transparent;
border-color: transparent;
}
&:focus {
outline: none; // Remove default outline
// Add custom focus style if needed, e.g., box-shadow
// For accessibility, ensure focus is visible.
// box-shadow: 0 0 0 0.1rem rgba(0, 123, 255, 0.25); // Example focus ring
}
// Better focus visibility, especially for keyboard navigation
&:focus-visible:not(.disabled) {
outline: 2px solid #007bff; // Standard outline
outline-offset: 2px;
// Or use box-shadow for a softer focus ring:
// box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.35);
}
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div role="tablist" class="tab-list">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
// No specific script logic for VTabList, it's a simple layout component.
// Name is set for component identification in Vue Devtools and VTabs onMounted logic.
defineOptions({
name: 'VTabList',
});
</script>
<style lang="scss" scoped>
.tab-list {
display: flex;
// Example styling:
border-bottom: 1px solid #dee2e6; // Standard Bootstrap-like border
margin-bottom: 0; // Remove any default margin if needed
// Prevent scrolling if tabs overflow, or add scroll styling
// overflow-x: auto;
// white-space: nowrap;
}
</style>

View File

@ -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 = '<button>Tab 1</button><button>Tab 2</button>';
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 = '<div>Panel 1 Content</div><div>Panel 2 Content</div>';
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');
});
});

View File

@ -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 = '<div>Panel Content Here</div>';
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();
});
});

View File

@ -0,0 +1,55 @@
<template>
<div
v-show="isActive"
role="tabpanel"
:id="'panel-' + id"
:aria-labelledby="ariaLabelledBy"
class="tab-content"
tabindex="0"
>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import type { TabId, TabsContext } from './types';
import { TabsProviderKey } from './types';
const props = defineProps<{
id: TabId;
}>();
defineOptions({
name: 'VTabPanel',
});
const tabsContext = inject<TabsContext>(TabsProviderKey);
if (!tabsContext) {
throw new Error('VTabPanel must be used within a VTabs component.');
}
const { activeTabId } = tabsContext;
const isActive = computed(() => activeTabId.value === props.id);
const ariaLabelledBy = computed(() => `tab-${props.id}`);
</script>
<style lang="scss" scoped>
.tab-content {
padding: 1.25rem; // Example padding, adjust as needed
// border: 1px solid #dee2e6; // If panels container doesn't have a border
// border-top: none;
background-color: #fff; // Ensure background for content
&:focus-visible { // For when panel itself is focused (e.g. after tab selection)
outline: 2px solid #007bff;
outline-offset: 2px;
// box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.35);
}
// Add styling for content within the panel if common patterns emerge
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div class="tab-panels-container">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
// No specific script logic for VTabPanels, it's a simple layout component.
// Name is set for component identification in Vue Devtools.
defineOptions({
name: 'VTabPanels',
});
</script>
<style lang="scss" scoped>
.tab-panels-container {
// This container wraps all VTabPanel components.
// It might have padding or other layout styles if needed.
// For example, if VTabPanel components don't have their own padding:
// padding: 1rem;
// border: 1px solid #dee2e6; // Example border matching tab-list
// border-top: none; // Avoid double border if tab-list has bottom border
// border-radius: 0 0 0.25rem 0.25rem; // Match overall tabs radius if any
}
</style>

View File

@ -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: `
<VTabs :modelValue="currentModelValue" @update:modelValue="val => currentModelValue = val" :initialTab="initialTabValue">
<VTabList>
<VTab id="tab1" title="Tab 1" />
<VTab id="tab2" title="Tab 2" />
</VTabList>
<VTabPanels>
<VTabPanel id="tab1"><p>Content 1</p></VTabPanel>
<VTabPanel id="tab2"><p>Content 2</p></VTabPanel>
</VTabPanels>
</VTabs>
`,
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: '<VTabList><VTab id="first"/><VTab id="second"/></VTabList><VTabPanels><VTabPanel id="first"/><VTabPanel id="second"/></VTabPanels>' },
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: '<VTabList><VTab id="first"/><VTab id="third"/></VTabList><VTabPanels><VTabPanel id="first"/><VTabPanel id="third"/></VTabPanels>' },
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: '<VTabList><VTab id="one"/><VTab id="two"/></VTabList><VTabPanels><VTabPanel id="one"/><VTabPanel id="two"/></VTabPanels>' },
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: '<VTabList><VTab id="alpha"/><VTab id="beta"/></VTabList><VTabPanels><VTabPanel id="alpha"/><VTabPanel id="beta"/></VTabPanels>' },
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: `
<VTabs>
<VTabList>
<VTab id="firstMounted" title="First" />
<VTab id="secondMounted" title="Second" />
</VTabList>
<VTabPanels>
<VTabPanel id="firstMounted">Content First</VTabPanel>
<VTabPanel id="secondMounted">Content Second</VTabPanel>
</VTabPanels>
</VTabs>
`,
};
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: `
<VTabs :modelValue="null">
<VTabList> <VTab id="t1" /> </VTabList>
<VTabPanels> <VTabPanel id="t1" /> </VTabPanels>
</VTabs>
`,
};
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: '<div class="test-slot-content">Hello</div>' },
});
expect(wrapper.find('.test-slot-content').exists()).toBe(true);
expect(wrapper.text()).toContain('Hello');
});
});

View File

@ -0,0 +1,94 @@
<template>
<div class="tabs">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, provide, onMounted, getCurrentInstance, type VNode } from 'vue';
import type { TabId, TabsContext } from './types';
import { TabsProviderKey } from './types';
const props = defineProps<{
modelValue?: TabId | null;
initialTab?: TabId | null;
}>();
const emit = defineEmits(['update:modelValue']);
const activeTabId = ref<TabId | null>(props.modelValue ?? props.initialTab ?? null);
watch(() => props.modelValue, (newVal) => {
if (newVal !== undefined && newVal !== null) {
activeTabId.value = newVal;
}
});
const selectTab = (tabId: TabId) => {
activeTabId.value = tabId;
emit('update:modelValue', tabId);
};
provide<TabsContext>(TabsProviderKey, {
activeTabId,
selectTab,
});
// Determine initial tab if not set by props
onMounted(() => {
if (activeTabId.value === null) {
// Try to find the first VTab's ID from slots
// This is a bit more complex due to Vue's slot structure
const instance = getCurrentInstance();
if (instance && instance.slots.default) {
const defaultSlots = instance.slots.default();
let firstTabId: TabId | null = null;
const findFirstTabRecursive = (nodes: VNode[]) => {
for (const node of nodes) {
if (firstTabId) break;
// Check if node is VTabList
if (node.type && (node.type as any).name === 'VTabList') {
if (node.children && Array.isArray(node.children)) {
// Children of VTabList could be VTab components directly or wrapped
for (const childNode of node.children as VNode[]) {
if (childNode.type && (childNode.type as any).name === 'VTab') {
if (childNode.props?.id) {
firstTabId = childNode.props.id;
break;
}
} else if (Array.isArray(childNode.children)) { // Handle cases where VTabs are nested in fragments or other elements
findFirstTabRecursive(childNode.children as VNode[]);
if (firstTabId) break;
}
}
} else if (typeof node.children === 'object' && node.children && 'default' in node.children) {
// If VTabList has its own default slot (e.g. from a render function)
// This part might need adjustment based on how VTabList is structured
}
} else if (node.children && Array.isArray(node.children)) {
findFirstTabRecursive(node.children as VNode[]);
}
}
};
findFirstTabRecursive(defaultSlots);
if (firstTabId) {
selectTab(firstTabId);
}
}
}
});
</script>
<style lang="scss" scoped>
.tabs {
// Basic container styling for the entire tabs system
// border: 1px solid #ccc; // Example border
// border-radius: 4px;
// overflow: hidden;
display: flex;
flex-direction: column; // Stack tablist and tabpanels
}
</style>

View File

@ -0,0 +1,10 @@
import type { Ref, InjectionKey } from 'vue';
export type TabId = string | number;
export interface TabsContext {
activeTabId: Ref<TabId | null>;
selectTab: (id: TabId) => void;
}
export const TabsProviderKey: InjectionKey<TabsContext> = Symbol('VTabs');