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:
parent
3811dc7ee5
commit
fc16f169b1
1
fe/src/components/valerie/.placeholder
Normal file
1
fe/src/components/valerie/.placeholder
Normal file
@ -0,0 +1 @@
|
||||
# This is a placeholder file to create the directory.
|
127
fe/src/components/valerie/VAlert.spec.ts
Normal file
127
fe/src/components/valerie/VAlert.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
245
fe/src/components/valerie/VAlert.stories.ts
Normal file
245
fe/src/components/valerie/VAlert.stories.ts
Normal 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,
|
||||
},
|
||||
};
|
184
fe/src/components/valerie/VAlert.vue
Normal file
184
fe/src/components/valerie/VAlert.vue
Normal 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>
|
115
fe/src/components/valerie/VAvatar.spec.ts
Normal file
115
fe/src/components/valerie/VAvatar.spec.ts
Normal 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
|
||||
});
|
||||
});
|
159
fe/src/components/valerie/VAvatar.stories.ts
Normal file
159
fe/src/components/valerie/VAvatar.stories.ts
Normal 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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
92
fe/src/components/valerie/VAvatar.vue
Normal file
92
fe/src/components/valerie/VAvatar.vue
Normal 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>
|
61
fe/src/components/valerie/VBadge.spec.ts
Normal file
61
fe/src/components/valerie/VBadge.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
96
fe/src/components/valerie/VBadge.stories.ts
Normal file
96
fe/src/components/valerie/VBadge.stories.ts
Normal 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
|
||||
},
|
||||
};
|
107
fe/src/components/valerie/VBadge.vue
Normal file
107
fe/src/components/valerie/VBadge.vue
Normal 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>
|
159
fe/src/components/valerie/VButton.spec.ts
Normal file
159
fe/src/components/valerie/VButton.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
153
fe/src/components/valerie/VButton.stories.ts
Normal file
153
fe/src/components/valerie/VButton.stories.ts
Normal 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>' })],
|
||||
};
|
207
fe/src/components/valerie/VButton.vue
Normal file
207
fe/src/components/valerie/VButton.vue
Normal 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>
|
140
fe/src/components/valerie/VCard.spec.ts
Normal file
140
fe/src/components/valerie/VCard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
164
fe/src/components/valerie/VCard.stories.ts
Normal file
164
fe/src/components/valerie/VCard.stories.ts
Normal 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
|
||||
},
|
||||
};
|
160
fe/src/components/valerie/VCard.vue
Normal file
160
fe/src/components/valerie/VCard.vue
Normal 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>
|
94
fe/src/components/valerie/VCheckbox.spec.ts
Normal file
94
fe/src/components/valerie/VCheckbox.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
151
fe/src/components/valerie/VCheckbox.stories.ts
Normal file
151
fe/src/components/valerie/VCheckbox.stories.ts
Normal 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',
|
||||
},
|
||||
};
|
146
fe/src/components/valerie/VCheckbox.vue
Normal file
146
fe/src/components/valerie/VCheckbox.vue
Normal 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>
|
112
fe/src/components/valerie/VFormField.spec.ts
Normal file
112
fe/src/components/valerie/VFormField.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
135
fe/src/components/valerie/VFormField.stories.ts
Normal file
135
fe/src/components/valerie/VFormField.stories.ts
Normal 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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
65
fe/src/components/valerie/VFormField.vue
Normal file
65
fe/src/components/valerie/VFormField.vue
Normal 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>
|
55
fe/src/components/valerie/VIcon.spec.ts
Normal file
55
fe/src/components/valerie/VIcon.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
51
fe/src/components/valerie/VIcon.stories.ts
Normal file
51
fe/src/components/valerie/VIcon.stories.ts
Normal 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.',
|
||||
},
|
||||
};
|
62
fe/src/components/valerie/VIcon.vue
Normal file
62
fe/src/components/valerie/VIcon.vue
Normal 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>
|
120
fe/src/components/valerie/VInput.spec.ts
Normal file
120
fe/src/components/valerie/VInput.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
202
fe/src/components/valerie/VInput.stories.ts
Normal file
202
fe/src/components/valerie/VInput.stories.ts
Normal 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
|
||||
},
|
||||
},
|
||||
};
|
127
fe/src/components/valerie/VInput.vue
Normal file
127
fe/src/components/valerie/VInput.vue
Normal 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>
|
54
fe/src/components/valerie/VList.spec.ts
Normal file
54
fe/src/components/valerie/VList.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
113
fe/src/components/valerie/VList.stories.ts
Normal file
113
fe/src/components/valerie/VList.stories.ts
Normal 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: {}
|
||||
};
|
31
fe/src/components/valerie/VList.vue
Normal file
31
fe/src/components/valerie/VList.vue
Normal 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>
|
116
fe/src/components/valerie/VListItem.spec.ts
Normal file
116
fe/src/components/valerie/VListItem.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
158
fe/src/components/valerie/VListItem.stories.ts
Normal file
158
fe/src/components/valerie/VListItem.stories.ts
Normal 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',
|
||||
},
|
||||
};
|
256
fe/src/components/valerie/VListItem.vue
Normal file
256
fe/src/components/valerie/VListItem.vue
Normal 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>
|
283
fe/src/components/valerie/VModal.spec.ts
Normal file
283
fe/src/components/valerie/VModal.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
275
fe/src/components/valerie/VModal.stories.ts
Normal file
275
fe/src/components/valerie/VModal.stories.ts
Normal 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
|
||||
},
|
||||
};
|
245
fe/src/components/valerie/VModal.vue
Normal file
245
fe/src/components/valerie/VModal.vue
Normal 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>
|
93
fe/src/components/valerie/VProgressBar.spec.ts
Normal file
93
fe/src/components/valerie/VProgressBar.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
166
fe/src/components/valerie/VProgressBar.stories.ts
Normal file
166
fe/src/components/valerie/VProgressBar.stories.ts
Normal 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',
|
||||
},
|
||||
};
|
125
fe/src/components/valerie/VProgressBar.vue
Normal file
125
fe/src/components/valerie/VProgressBar.vue
Normal 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>
|
129
fe/src/components/valerie/VRadio.spec.ts
Normal file
129
fe/src/components/valerie/VRadio.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
176
fe/src/components/valerie/VRadio.stories.ts
Normal file
176
fe/src/components/valerie/VRadio.stories.ts
Normal 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.' },
|
||||
},
|
||||
},
|
||||
};
|
165
fe/src/components/valerie/VRadio.vue
Normal file
165
fe/src/components/valerie/VRadio.vue
Normal 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>
|
132
fe/src/components/valerie/VSelect.spec.ts
Normal file
132
fe/src/components/valerie/VSelect.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
197
fe/src/components/valerie/VSelect.stories.ts
Normal file
197
fe/src/components/valerie/VSelect.stories.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
158
fe/src/components/valerie/VSelect.vue
Normal file
158
fe/src/components/valerie/VSelect.vue
Normal 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>
|
117
fe/src/components/valerie/VTextarea.spec.ts
Normal file
117
fe/src/components/valerie/VTextarea.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
173
fe/src/components/valerie/VTextarea.stories.ts
Normal file
173
fe/src/components/valerie/VTextarea.stories.ts
Normal 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
|
||||
},
|
||||
},
|
||||
};
|
139
fe/src/components/valerie/VTextarea.vue
Normal file
139
fe/src/components/valerie/VTextarea.vue
Normal 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>
|
109
fe/src/components/valerie/VToggleSwitch.spec.ts
Normal file
109
fe/src/components/valerie/VToggleSwitch.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
138
fe/src/components/valerie/VToggleSwitch.stories.ts
Normal file
138
fe/src/components/valerie/VToggleSwitch.stories.ts
Normal 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.' },
|
||||
},
|
||||
},
|
||||
};
|
184
fe/src/components/valerie/VToggleSwitch.vue
Normal file
184
fe/src/components/valerie/VToggleSwitch.vue
Normal 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>
|
206
fe/src/components/valerie/tabs/Tabs.stories.ts
Normal file
206
fe/src/components/valerie/tabs/Tabs.stories.ts
Normal 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
|
||||
},
|
||||
};
|
108
fe/src/components/valerie/tabs/VTab.spec.ts
Normal file
108
fe/src/components/valerie/tabs/VTab.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
99
fe/src/components/valerie/tabs/VTab.vue
Normal file
99
fe/src/components/valerie/tabs/VTab.vue
Normal 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>
|
25
fe/src/components/valerie/tabs/VTabList.vue
Normal file
25
fe/src/components/valerie/tabs/VTabList.vue
Normal 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>
|
35
fe/src/components/valerie/tabs/VTabListAndPanels.spec.ts
Normal file
35
fe/src/components/valerie/tabs/VTabListAndPanels.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
72
fe/src/components/valerie/tabs/VTabPanel.spec.ts
Normal file
72
fe/src/components/valerie/tabs/VTabPanel.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
55
fe/src/components/valerie/tabs/VTabPanel.vue
Normal file
55
fe/src/components/valerie/tabs/VTabPanel.vue
Normal 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>
|
25
fe/src/components/valerie/tabs/VTabPanels.vue
Normal file
25
fe/src/components/valerie/tabs/VTabPanels.vue
Normal 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>
|
135
fe/src/components/valerie/tabs/VTabs.spec.ts
Normal file
135
fe/src/components/valerie/tabs/VTabs.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
94
fe/src/components/valerie/tabs/VTabs.vue
Normal file
94
fe/src/components/valerie/tabs/VTabs.vue
Normal 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>
|
10
fe/src/components/valerie/tabs/types.ts
Normal file
10
fe/src/components/valerie/tabs/types.ts
Normal 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');
|
Loading…
Reference in New Issue
Block a user