chore: Remove obsolete test files and assets (valerie-ui) - step 1
@ -1,14 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import EssentialLink from '../EssentialLink.vue'; // Adjust path as necessary
|
||||
|
||||
describe('EssentialLink', () => {
|
||||
it('renders correctly', () => {
|
||||
const wrapper = mount(EssentialLink, {
|
||||
props: {
|
||||
title: 'Test Title',
|
||||
link: 'test-link',
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain('Test Title');
|
||||
});
|
||||
});
|
@ -1,181 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { useNotificationStore, type Notification } from '@/stores/notifications';
|
||||
import NotificationDisplay from '@/components/global/NotificationDisplay.vue';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
// Helper to generate unique IDs for test notifications
|
||||
let idCounter = 0;
|
||||
const generateTestId = () => `test_notif_${idCounter++}`;
|
||||
|
||||
describe('NotificationDisplay.vue', () => {
|
||||
beforeEach(() => {
|
||||
// Reset idCounter for consistent IDs across tests
|
||||
idCounter = 0;
|
||||
});
|
||||
|
||||
it('renders correctly when there are no notifications', () => {
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
notifications: { notifications: [] },
|
||||
},
|
||||
stubActions: false, // We want to spy on actions
|
||||
});
|
||||
|
||||
const wrapper = mount(NotificationDisplay, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find('.notification-container').exists()).toBe(true);
|
||||
expect(wrapper.findAll('.alert').length).toBe(0);
|
||||
});
|
||||
|
||||
it('displays a single notification with correct message and type', async () => {
|
||||
const testNotification: Notification = {
|
||||
id: generateTestId(),
|
||||
message: 'Test Success!',
|
||||
type: 'success',
|
||||
};
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
notifications: { notifications: [testNotification] },
|
||||
},
|
||||
stubActions: false,
|
||||
});
|
||||
|
||||
const wrapper = mount(NotificationDisplay, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick(); // Wait for reactivity
|
||||
|
||||
const notificationElements = wrapper.findAll('.alert');
|
||||
expect(notificationElements.length).toBe(1);
|
||||
const notificationElement = notificationElements[0];
|
||||
expect(notificationElement.text()).toContain('Test Success!');
|
||||
expect(notificationElement.classes()).toContain('alert-success');
|
||||
// Check for success icon (specific path data might be too brittle, check for presence of svg)
|
||||
expect(notificationElement.find('svg[fill="currentColor"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('displays multiple notifications', async () => {
|
||||
const notificationsList: Notification[] = [
|
||||
{ id: generateTestId(), message: 'Error occurred', type: 'error' },
|
||||
{ id: generateTestId(), message: 'Info message', type: 'info' },
|
||||
];
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
notifications: { notifications: notificationsList },
|
||||
},
|
||||
stubActions: false,
|
||||
});
|
||||
|
||||
const wrapper = mount(NotificationDisplay, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const notificationElements = wrapper.findAll('.alert');
|
||||
expect(notificationElements.length).toBe(2);
|
||||
expect(notificationElements[0].text()).toContain('Error occurred');
|
||||
expect(notificationElements[0].classes()).toContain('alert-error');
|
||||
expect(notificationElements[1].text()).toContain('Info message');
|
||||
expect(notificationElements[1].classes()).toContain('alert-info');
|
||||
});
|
||||
|
||||
it('calls store.removeNotification when a notification close button is clicked', async () => {
|
||||
const notifIdToRemove = generateTestId();
|
||||
const initialNotifications: Notification[] = [
|
||||
{ id: notifIdToRemove, message: 'Dismiss me', type: 'warning' },
|
||||
{ id: generateTestId(), message: 'Keep me', type: 'info' },
|
||||
];
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
notifications: { notifications: [...initialNotifications] },
|
||||
},
|
||||
stubActions: false, // So we can spy on the actual action
|
||||
plugins: [
|
||||
({ store }) => {
|
||||
if (store.$id === 'notifications') {
|
||||
// Spy on the actual action if not stubbed
|
||||
// @ts-ignore
|
||||
store.removeNotification = vi.fn(store.removeNotification);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const notificationStore = useNotificationStore(pinia);
|
||||
|
||||
const wrapper = mount(NotificationDisplay, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const notificationElements = wrapper.findAll('.alert');
|
||||
expect(notificationElements.length).toBe(2);
|
||||
|
||||
const firstNotificationCloseButton = notificationElements[0].find('.alert-close-btn');
|
||||
expect(firstNotificationCloseButton.exists()).toBe(true);
|
||||
await firstNotificationCloseButton.trigger('click');
|
||||
|
||||
expect(notificationStore.removeNotification).toHaveBeenCalledTimes(1);
|
||||
expect(notificationStore.removeNotification).toHaveBeenCalledWith(notifIdToRemove);
|
||||
|
||||
// Simulate store removing the notification
|
||||
// In a real scenario, the action would modify the state.
|
||||
// Here we check the spy, and can also manually update state for UI check.
|
||||
// @ts-ignore
|
||||
notificationStore.notifications = initialNotifications.filter(n => n.id !== notifIdToRemove);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findAll('.alert').length).toBe(1);
|
||||
expect(wrapper.text()).not.toContain('Dismiss me');
|
||||
expect(wrapper.text()).toContain('Keep me');
|
||||
});
|
||||
|
||||
it('renders correct icons for different notification types', async () => {
|
||||
const notificationsList: Notification[] = [
|
||||
{ id: generateTestId(), message: 'Success', type: 'success' },
|
||||
{ id: generateTestId(), message: 'Error', type: 'error' },
|
||||
{ id: generateTestId(), message: 'Warning', type: 'warning' },
|
||||
{ id: generateTestId(), message: 'Info', type: 'info' },
|
||||
];
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
notifications: { notifications: notificationsList },
|
||||
},
|
||||
stubActions: false,
|
||||
});
|
||||
|
||||
const wrapper = mount(NotificationDisplay, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const alerts = wrapper.findAll('.alert');
|
||||
expect(alerts[0].find('svg[viewBox="0 0 24 24"]').exists()).toBe(true); // Basic check for any SVG
|
||||
expect(alerts[0].classes()).toContain('alert-success');
|
||||
|
||||
expect(alerts[1].find('svg[viewBox="0 0 24 24"]').exists()).toBe(true);
|
||||
expect(alerts[1].classes()).toContain('alert-error');
|
||||
|
||||
expect(alerts[2].find('svg[viewBox="0 0 24 24"]').exists()).toBe(true);
|
||||
expect(alerts[2].classes()).toContain('alert-warning');
|
||||
|
||||
expect(alerts[3].find('svg[viewBox="0 0 24 24"]').exists()).toBe(true);
|
||||
expect(alerts[3].classes()).toContain('alert-info');
|
||||
});
|
||||
});
|
@ -1,134 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mount, VueWrapper } from '@vue/test-utils';
|
||||
import { Decimal } from 'decimal.js';
|
||||
|
||||
import SettleShareModal from '../SettleShareModal.vue'; // Adjust path as needed
|
||||
import type { ExpenseSplitInfo } from '../SettleShareModal.vue'; // Import the interface
|
||||
|
||||
// Default props generator
|
||||
const getDefaultProps = (overrides: Record<string, any> = {}) => ({
|
||||
show: true,
|
||||
split: {
|
||||
id: 1,
|
||||
user_id: 100,
|
||||
owed_amount: '50.00',
|
||||
user: { id: 100, name: 'Test User', email: 'user@example.com' },
|
||||
} as ExpenseSplitInfo,
|
||||
paidAmount: 10.00,
|
||||
isLoading: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SettleShareModal.vue', () => {
|
||||
let wrapper: VueWrapper<any>;
|
||||
|
||||
const mountComponent = (props: Record<string, any>) => {
|
||||
wrapper = mount(SettleShareModal, {
|
||||
props: getDefaultProps(props),
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Default mount before each test, can be overridden in specific tests
|
||||
mountComponent({});
|
||||
});
|
||||
|
||||
it('renders when show is true', () => {
|
||||
expect(wrapper.find('.modal-backdrop-settle').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render when show is false', () => {
|
||||
mountComponent({ show: false });
|
||||
expect(wrapper.find('.modal-backdrop-settle').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('displays correct split information', () => {
|
||||
const props = getDefaultProps({
|
||||
split: {
|
||||
id: 2,
|
||||
user_id: 101,
|
||||
owed_amount: '75.50',
|
||||
user: { id: 101, name: 'Jane Doe', email: 'jane@example.com' },
|
||||
},
|
||||
paidAmount: 25.00,
|
||||
});
|
||||
mountComponent(props);
|
||||
|
||||
const html = wrapper.html();
|
||||
expect(html).toContain('Jane Doe');
|
||||
expect(html).toContain('$75.50'); // Owed amount
|
||||
expect(html).toContain('$25.00'); // Paid amount
|
||||
|
||||
const expectedRemaining = new Decimal(props.split.owed_amount).minus(new Decimal(props.paidAmount)).toFixed(2);
|
||||
expect(html).toContain(`$${expectedRemaining}`); // Remaining amount
|
||||
});
|
||||
|
||||
it('calculates and displays correct remaining amount', () => {
|
||||
const owed = '100.00';
|
||||
const paid = 30.00;
|
||||
const remaining = new Decimal(owed).minus(paid).toFixed(2);
|
||||
mountComponent({ split: { ...getDefaultProps().split, owed_amount: owed }, paidAmount: paid });
|
||||
|
||||
const remainingAmountStrong = wrapper.find('.amount-to-settle');
|
||||
expect(remainingAmountStrong.exists()).toBe(true);
|
||||
expect(remainingAmountStrong.text()).toBe(`$${remaining}`);
|
||||
});
|
||||
|
||||
it('emits "confirm" with correct amount when Confirm Payment is clicked', async () => {
|
||||
const owed = '50.00';
|
||||
const paid = 10.00;
|
||||
const expectedSettleAmount = new Decimal(owed).minus(paid).toNumber();
|
||||
|
||||
mountComponent({
|
||||
split: { ...getDefaultProps().split, owed_amount: owed },
|
||||
paidAmount: paid
|
||||
});
|
||||
|
||||
await wrapper.find('.btn-primary-settle').trigger('click');
|
||||
expect(wrapper.emitted().confirm).toBeTruthy();
|
||||
expect(wrapper.emitted().confirm[0]).toEqual([expectedSettleAmount]);
|
||||
});
|
||||
|
||||
it('emits "cancel" when Cancel button is clicked', async () => {
|
||||
await wrapper.find('.btn-neutral-settle').trigger('click');
|
||||
expect(wrapper.emitted().cancel).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits "cancel" when backdrop is clicked', async () => {
|
||||
await wrapper.find('.modal-backdrop-settle').trigger('click.self');
|
||||
expect(wrapper.emitted().cancel).toBeTruthy();
|
||||
});
|
||||
|
||||
it('disables Confirm Payment button when isLoading is true', () => {
|
||||
mountComponent({ isLoading: true });
|
||||
const confirmButton = wrapper.find('.btn-primary-settle');
|
||||
expect((confirmButton.element as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disables Confirm Payment button when remaining amount is zero or less', () => {
|
||||
mountComponent({
|
||||
split: { ...getDefaultProps().split, owed_amount: '20.00' },
|
||||
paidAmount: 20.00
|
||||
}); // remaining is 0
|
||||
const confirmButton = wrapper.find('.btn-primary-settle');
|
||||
expect((confirmButton.element as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
mountComponent({
|
||||
split: { ...getDefaultProps().split, owed_amount: '19.00' },
|
||||
paidAmount: 20.00
|
||||
}); // remaining is < 0 (overpaid)
|
||||
const confirmButtonNegative = wrapper.find('.btn-primary-settle');
|
||||
expect((confirmButtonNegative.element as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('Confirm Payment button is enabled when there is a positive remaining amount and not loading', () => {
|
||||
mountComponent({
|
||||
split: { ...getDefaultProps().split, owed_amount: '20.00' },
|
||||
paidAmount: 10.00,
|
||||
isLoading: false
|
||||
});
|
||||
const confirmButton = wrapper.find('.btn-primary-settle');
|
||||
expect((confirmButton.element as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
});
|
@ -1,60 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import SocialLoginButtons from '../SocialLoginButtons.vue';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('SocialLoginButtons.vue', () => {
|
||||
it('renders the component correctly', () => {
|
||||
const wrapper = mount(SocialLoginButtons);
|
||||
expect(wrapper.find('.social-login-container').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('or continue with');
|
||||
});
|
||||
|
||||
it('renders the Google login button', () => {
|
||||
const wrapper = mount(SocialLoginButtons);
|
||||
const googleButton = wrapper.find('.btn-google');
|
||||
expect(googleButton.exists()).toBe(true);
|
||||
expect(googleButton.text()).toContain('Continue with Google');
|
||||
});
|
||||
|
||||
// Since the Apple button is commented out, we won't test for its presence by default.
|
||||
// If it were to be enabled by a prop, we would add tests for that.
|
||||
|
||||
it('calls handleGoogleLogin when Google button is clicked', async () => {
|
||||
// Mock window.location.href to prevent actual redirection
|
||||
// and to check if it's called.
|
||||
const originalLocation = window.location;
|
||||
// @ts-ignore
|
||||
delete window.location;
|
||||
// @ts-ignore
|
||||
window.location = { href: '' };
|
||||
|
||||
const wrapper = mount(SocialLoginButtons);
|
||||
const googleButton = wrapper.find('.btn-google');
|
||||
await googleButton.trigger('click');
|
||||
|
||||
// Check if window.location.href was set as expected
|
||||
expect(window.location.href).toBe('/auth/google/login');
|
||||
|
||||
// Restore original window.location
|
||||
window.location = originalLocation;
|
||||
});
|
||||
|
||||
// If handleAppleLogin were active and the button present, a similar test would be here.
|
||||
// it('calls handleAppleLogin when Apple button is clicked', async () => {
|
||||
// const wrapper = mount(SocialLoginButtons);
|
||||
// // Mock handleAppleLogin if it were complex, or spy on window.location.href
|
||||
// // For this example, let's assume it would also change window.location.href
|
||||
// const originalLocation = window.location;
|
||||
// // @ts-ignore
|
||||
// delete window.location;
|
||||
// // @ts-ignore
|
||||
// window.location = { href: '' };
|
||||
|
||||
// const appleButton = wrapper.find('.btn-apple'); // This would fail as it's commented out
|
||||
// if (appleButton.exists()) { // Check to prevent error if button doesn't exist
|
||||
// await appleButton.trigger('click');
|
||||
// expect(window.location.href).toBe('/auth/apple/login');
|
||||
// }
|
||||
// window.location = originalLocation;
|
||||
// });
|
||||
});
|
@ -1 +0,0 @@
|
||||
# This is a placeholder file to create the directory.
|
@ -1,127 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
@ -1,245 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
@ -1,184 +0,0 @@
|
||||
<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>
|
@ -1,115 +0,0 @@
|
||||
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
|
||||
});
|
||||
});
|
@ -1,159 +0,0 @@
|
||||
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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
@ -1,92 +0,0 @@
|
||||
<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>
|
@ -1,61 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
@ -1,96 +0,0 @@
|
||||
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
|
||||
},
|
||||
};
|
@ -1,136 +0,0 @@
|
||||
<template>
|
||||
<span :class="badgeClasses">{{ text }}</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, type PropType } from 'vue';
|
||||
|
||||
export type BadgeVariant = 'primary' | 'secondary' | 'accent' | 'settled' | 'pending' | 'success' | 'danger' | 'warning' | 'info' | 'neutral';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'VBadge',
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
variant: {
|
||||
type: String as PropType<BadgeVariant>,
|
||||
default: 'secondary',
|
||||
validator: (value: string) => ['primary', 'secondary', 'accent', 'settled', 'pending', 'success', 'danger', 'warning', 'info', 'neutral'].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: #856404; // Using a darker color for better contrast
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: #CCE5FF;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #D1E7DD;
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #F8D7DA;
|
||||
color: #721C24;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #FFF3CD;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: #D1ECF1;
|
||||
color: #0C5460;
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background-color: #F8F9FA;
|
||||
color: #343A40;
|
||||
}
|
||||
</style>
|
@ -1,159 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
@ -1,153 +0,0 @@
|
||||
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>' })],
|
||||
};
|
@ -1,218 +0,0 @@
|
||||
<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, type PropType } from 'vue';
|
||||
import VIcon from './VIcon.vue'; // Assuming VIcon.vue is in the same directory
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger' | 'success';
|
||||
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', 'success'].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;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745; // Example success color
|
||||
color: white;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
// 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>
|
@ -1,140 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,164 +0,0 @@
|
||||
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
|
||||
},
|
||||
};
|
@ -1,160 +0,0 @@
|
||||
<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>
|
@ -1,94 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
@ -1,151 +0,0 @@
|
||||
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',
|
||||
},
|
||||
};
|
@ -1,146 +0,0 @@
|
||||
<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>
|
@ -1,112 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
@ -1,135 +0,0 @@
|
||||
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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
@ -1,65 +0,0 @@
|
||||
<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>
|
@ -1,65 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import VHeading from './VHeading.vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('VHeading.vue', () => {
|
||||
it('renders correct heading tag based on level prop', () => {
|
||||
const wrapperH1 = mount(VHeading, { props: { level: 1, text: 'H1' } });
|
||||
expect(wrapperH1.element.tagName).toBe('H1');
|
||||
|
||||
const wrapperH2 = mount(VHeading, { props: { level: 2, text: 'H2' } });
|
||||
expect(wrapperH2.element.tagName).toBe('H2');
|
||||
|
||||
const wrapperH3 = mount(VHeading, { props: { level: 3, text: 'H3' } });
|
||||
expect(wrapperH3.element.tagName).toBe('H3');
|
||||
});
|
||||
|
||||
it('renders text prop content when no default slot', () => {
|
||||
const headingText = 'My Awesome Heading';
|
||||
const wrapper = mount(VHeading, { props: { level: 1, text: headingText } });
|
||||
expect(wrapper.text()).toBe(headingText);
|
||||
});
|
||||
|
||||
it('renders default slot content instead of text prop', () => {
|
||||
const slotContent = '<em>Custom Slot Heading</em>';
|
||||
const wrapper = mount(VHeading, {
|
||||
props: { level: 2, text: 'Ignored Text Prop' },
|
||||
slots: { default: slotContent },
|
||||
});
|
||||
expect(wrapper.html()).toContain(slotContent);
|
||||
expect(wrapper.text()).not.toBe('Ignored Text Prop'); // Check text() to be sure
|
||||
expect(wrapper.find('em').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('applies id attribute when id prop is provided', () => {
|
||||
const headingId = 'section-title-1';
|
||||
const wrapper = mount(VHeading, { props: { level: 1, id: headingId } });
|
||||
expect(wrapper.attributes('id')).toBe(headingId);
|
||||
});
|
||||
|
||||
it('does not have an id attribute if id prop is not provided', () => {
|
||||
const wrapper = mount(VHeading, { props: { level: 1 } });
|
||||
expect(wrapper.attributes('id')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates level prop correctly', () => {
|
||||
const validator = VHeading.props.level.validator;
|
||||
expect(validator(1)).toBe(true);
|
||||
expect(validator(2)).toBe(true);
|
||||
expect(validator(3)).toBe(true);
|
||||
expect(validator(4)).toBe(false);
|
||||
expect(validator(0)).toBe(false);
|
||||
expect(validator('1')).toBe(false); // Expects a number
|
||||
});
|
||||
|
||||
it('renders an empty heading if text prop is empty and no slot', () => {
|
||||
const wrapper = mount(VHeading, { props: { level: 1, text: '' } });
|
||||
expect(wrapper.text()).toBe('');
|
||||
expect(wrapper.element.children.length).toBe(0); // No child nodes
|
||||
});
|
||||
|
||||
it('renders correctly if text prop is not provided (defaults to empty string)', () => {
|
||||
const wrapper = mount(VHeading, { props: { level: 1 } }); // text prop is optional, defaults to ''
|
||||
expect(wrapper.text()).toBe('');
|
||||
});
|
||||
});
|
@ -1,100 +0,0 @@
|
||||
import VHeading from './VHeading.vue';
|
||||
import VIcon from './VIcon.vue'; // For custom slot content example
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
const meta: Meta<typeof VHeading> = {
|
||||
title: 'Valerie/VHeading',
|
||||
component: VHeading,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
level: {
|
||||
control: { type: 'select' },
|
||||
options: [1, 2, 3],
|
||||
description: 'Determines the heading tag (1 for h1, 2 for h2, 3 for h3).',
|
||||
},
|
||||
text: { control: 'text', description: 'Text content of the heading (ignored if default slot is used).' },
|
||||
id: { control: 'text', description: 'Optional ID for the heading element.' },
|
||||
default: { description: 'Slot for custom heading content (overrides text prop).', table: { disable: true } },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'A dynamic heading component that renders `<h1>`, `<h2>`, or `<h3>` tags based on the `level` prop. It relies on global styles for h1, h2, h3 from `valerie-ui.scss`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof VHeading>;
|
||||
|
||||
export const Level1: Story = {
|
||||
args: {
|
||||
level: 1,
|
||||
text: 'This is an H1 Heading',
|
||||
id: 'heading-level-1',
|
||||
},
|
||||
};
|
||||
|
||||
export const Level2: Story = {
|
||||
args: {
|
||||
level: 2,
|
||||
text: 'This is an H2 Heading',
|
||||
},
|
||||
};
|
||||
|
||||
export const Level3: Story = {
|
||||
args: {
|
||||
level: 3,
|
||||
text: 'This is an H3 Heading',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomSlotContent: Story = {
|
||||
render: (args) => ({
|
||||
components: { VHeading, VIcon },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: `
|
||||
<VHeading :level="args.level" :id="args.id">
|
||||
<span>Custom Content with an Icon <VIcon name="alert" size="sm" style="color: #007bff;" /></span>
|
||||
</VHeading>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
level: 2,
|
||||
id: 'custom-content-heading',
|
||||
// text prop is ignored when default slot is used
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Demonstrates using the default slot for more complex heading content, such as text with an inline icon. The `text` prop is ignored in this case.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithId: Story = {
|
||||
args: {
|
||||
level: 3,
|
||||
text: 'Heading with a Specific ID',
|
||||
id: 'my-section-title',
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyTextPropAndNoSlot: Story = {
|
||||
args: {
|
||||
level: 2,
|
||||
text: '', // Empty text prop
|
||||
// No default slot content
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Renders an empty heading tag (e.g., `<h2></h2>`) if both the `text` prop is empty and no default slot content is provided.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<component :is="tagName" :id="id">
|
||||
<slot>{{ text }}</slot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
level: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: (value: number) => [1, 2, 3].includes(value),
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const tagName = computed(() => {
|
||||
if (props.level === 1) return 'h1';
|
||||
if (props.level === 2) return 'h2';
|
||||
if (props.level === 3) return 'h3';
|
||||
return 'h2'; // Fallback, though validator should prevent this
|
||||
});
|
||||
|
||||
// No specific SCSS needed here as it relies on global h1, h2, h3 styles
|
||||
// from valerie-ui.scss.
|
||||
</script>
|
@ -1,55 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
@ -1,51 +0,0 @@
|
||||
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.',
|
||||
},
|
||||
};
|
@ -1,62 +0,0 @@
|
||||
<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>
|
@ -1,120 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
@ -1,202 +0,0 @@
|
||||
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
|
||||
},
|
||||
},
|
||||
};
|
@ -1,127 +0,0 @@
|
||||
<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>
|
@ -1,54 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
@ -1,113 +0,0 @@
|
||||
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: {}
|
||||
};
|
@ -1,31 +0,0 @@
|
||||
<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>
|
@ -1,116 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
@ -1,158 +0,0 @@
|
||||
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',
|
||||
},
|
||||
};
|
@ -1,263 +0,0 @@
|
||||
<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>
|
@ -1,283 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
@ -1,275 +0,0 @@
|
||||
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
|
||||
},
|
||||
};
|
@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade" @after-enter="onOpened" @after-leave="onClosed">
|
||||
<div v-if="modelValue" class="modal-backdrop open" @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">
|
||||
// Global style for body when modal is open - this is the only style needed
|
||||
// All other modal styles are in valerie-ui.scss
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
@ -1,93 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
@ -1,166 +0,0 @@
|
||||
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',
|
||||
},
|
||||
};
|
@ -1,125 +0,0 @@
|
||||
<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>
|
@ -1,129 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
@ -1,176 +0,0 @@
|
||||
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.' },
|
||||
},
|
||||
},
|
||||
};
|
@ -1,165 +0,0 @@
|
||||
<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>
|
@ -1,132 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
@ -1,197 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
@ -1,158 +0,0 @@
|
||||
<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>
|
@ -1,55 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import VSpinner from './VSpinner.vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('VSpinner.vue', () => {
|
||||
it('applies default "md" size (no specific class for md, just .spinner-dots)', () => {
|
||||
const wrapper = mount(VSpinner);
|
||||
expect(wrapper.classes()).toContain('spinner-dots');
|
||||
// Check that it does NOT have sm class unless specified
|
||||
expect(wrapper.classes()).not.toContain('spinner-dots-sm');
|
||||
});
|
||||
|
||||
it('applies .spinner-dots-sm class when size is "sm"', () => {
|
||||
const wrapper = mount(VSpinner, { props: { size: 'sm' } });
|
||||
expect(wrapper.classes()).toContain('spinner-dots'); // Base class
|
||||
expect(wrapper.classes()).toContain('spinner-dots-sm'); // Size specific class
|
||||
});
|
||||
|
||||
it('does not apply .spinner-dots-sm class when size is "md"', () => {
|
||||
const wrapper = mount(VSpinner, { props: { size: 'md' } });
|
||||
expect(wrapper.classes()).toContain('spinner-dots');
|
||||
expect(wrapper.classes()).not.toContain('spinner-dots-sm');
|
||||
});
|
||||
|
||||
|
||||
it('sets aria-label attribute with the label prop value', () => {
|
||||
const labelText = 'Fetching data, please wait...';
|
||||
const wrapper = mount(VSpinner, { props: { label: labelText } });
|
||||
expect(wrapper.attributes('aria-label')).toBe(labelText);
|
||||
});
|
||||
|
||||
it('sets default aria-label "Loading..." if label prop is not provided', () => {
|
||||
const wrapper = mount(VSpinner); // No label prop
|
||||
expect(wrapper.attributes('aria-label')).toBe('Loading...');
|
||||
});
|
||||
|
||||
it('has role="status" attribute', () => {
|
||||
const wrapper = mount(VSpinner);
|
||||
expect(wrapper.attributes('role')).toBe('status');
|
||||
});
|
||||
|
||||
it('renders three <span> elements for the dots', () => {
|
||||
const wrapper = mount(VSpinner);
|
||||
const dotSpans = wrapper.findAll('span');
|
||||
expect(dotSpans.length).toBe(3);
|
||||
});
|
||||
|
||||
it('validates size prop correctly', () => {
|
||||
const validator = VSpinner.props.size.validator;
|
||||
expect(validator('sm')).toBe(true);
|
||||
expect(validator('md')).toBe(true);
|
||||
expect(validator('lg')).toBe(false); // lg is not a valid size
|
||||
expect(validator('')).toBe(false);
|
||||
});
|
||||
});
|
@ -1,64 +0,0 @@
|
||||
import VSpinner from './VSpinner.vue';
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
const meta: Meta<typeof VSpinner> = {
|
||||
title: 'Valerie/VSpinner',
|
||||
component: VSpinner,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md'],
|
||||
description: 'Size of the spinner.',
|
||||
},
|
||||
label: {
|
||||
control: 'text',
|
||||
description: 'Accessible label for the spinner (visually hidden).',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'A simple animated spinner component to indicate loading states. It uses CSS animations for the dots and provides accessibility attributes.',
|
||||
},
|
||||
},
|
||||
layout: 'centered', // Center the spinner in the story
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof VSpinner>;
|
||||
|
||||
export const DefaultSizeMedium: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
label: 'Loading content...',
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallSize: Story = {
|
||||
args: {
|
||||
size: 'sm',
|
||||
label: 'Processing small task...',
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomLabel: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
label: 'Please wait while data is being fetched.',
|
||||
},
|
||||
};
|
||||
|
||||
export const OnlySpinnerNoLabelArg: Story = {
|
||||
// The component has a default label "Loading..."
|
||||
args: {
|
||||
size: 'md',
|
||||
// label prop not set, should use default
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: { story: 'Spinner using the default accessible label "Loading..." when the `label` prop is not explicitly provided.' },
|
||||
},
|
||||
},
|
||||
};
|
@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
:aria-label="label"
|
||||
class="spinner-dots"
|
||||
:class="sizeClass"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
size: {
|
||||
type: String, // 'sm', 'md'
|
||||
default: 'md',
|
||||
validator: (value: string) => ['sm', 'md'].includes(value),
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Loading...',
|
||||
},
|
||||
});
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
// Based on valerie-ui.scss, 'spinner-dots' is the medium size.
|
||||
// Only 'sm' size needs an additional specific class.
|
||||
return props.size === 'sm' ? 'spinner-dots-sm' : null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Styles for .spinner-dots and .spinner-dots-sm are assumed to be globally available
|
||||
// from valerie-ui.scss or a similar imported stylesheet.
|
||||
// For completeness in a standalone component context, they would be defined here.
|
||||
// Example (from valerie-ui.scss structure):
|
||||
|
||||
// .spinner-dots {
|
||||
// display: inline-flex; // Changed from inline-block for better flex alignment if needed
|
||||
// align-items: center; // Align dots vertically if their heights differ (should not with this CSS)
|
||||
// justify-content: space-around; // Distribute dots if container has more space (width affects this)
|
||||
// // Default (medium) size variables from valerie-ui.scss
|
||||
// // --spinner-dot-size: 8px;
|
||||
// // --spinner-spacing: 2px;
|
||||
// // width: calc(var(--spinner-dot-size) * 3 + var(--spinner-spacing) * 2);
|
||||
// // height: var(--spinner-dot-size);
|
||||
|
||||
// span {
|
||||
// display: inline-block;
|
||||
// width: var(--spinner-dot-size, 8px);
|
||||
// height: var(--spinner-dot-size, 8px);
|
||||
// margin: 0 var(--spinner-spacing, 2px); // Replaces justify-content if width is tight
|
||||
// border-radius: 50%;
|
||||
// background-color: var(--spinner-color, #007bff); // Use a CSS variable for color
|
||||
// animation: spinner-dots-bounce 1.4s infinite ease-in-out both;
|
||||
|
||||
// &:first-child { margin-left: 0; }
|
||||
// &:last-child { margin-right: 0; }
|
||||
|
||||
// &:nth-child(1) {
|
||||
// animation-delay: -0.32s;
|
||||
// }
|
||||
// &:nth-child(2) {
|
||||
// animation-delay: -0.16s;
|
||||
// }
|
||||
// // nth-child(3) has no delay by default in the animation
|
||||
// }
|
||||
// }
|
||||
|
||||
// .spinner-dots-sm {
|
||||
// // Override CSS variables for small size
|
||||
// --spinner-dot-size: 6px;
|
||||
// --spinner-spacing: 1px;
|
||||
// // Width and height will adjust based on the new variable values if .spinner-dots uses them.
|
||||
// }
|
||||
|
||||
// @keyframes spinner-dots-bounce {
|
||||
// 0%, 80%, 100% {
|
||||
// transform: scale(0);
|
||||
// }
|
||||
// 40% {
|
||||
// transform: scale(1.0);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Since this component relies on styles from valerie-ui.scss,
|
||||
// ensure that valerie-ui.scss is imported in the application's global styles
|
||||
// or in a higher-level component. If these styles are not present globally,
|
||||
// the spinner will not render correctly.
|
||||
// For Storybook, this means valerie-ui.scss needs to be imported in .storybook/preview.js or similar.
|
||||
</style>
|
@ -1,162 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import VTable from './VTable.vue';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
const testHeaders = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Name', headerClass: 'name-header', cellClass: 'name-cell' },
|
||||
{ key: 'email', label: 'Email Address' },
|
||||
];
|
||||
|
||||
const testItems = [
|
||||
{ id: 1, name: 'Alice', email: 'alice@example.com' },
|
||||
{ id: 2, name: 'Bob', email: 'bob@example.com' },
|
||||
];
|
||||
|
||||
describe('VTable.vue', () => {
|
||||
it('renders headers correctly', () => {
|
||||
const wrapper = mount(VTable, { props: { headers: testHeaders, items: [] } });
|
||||
const thElements = wrapper.findAll('thead th');
|
||||
expect(thElements.length).toBe(testHeaders.length);
|
||||
testHeaders.forEach((header, index) => {
|
||||
expect(thElements[index].text()).toBe(header.label);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders item data correctly', () => {
|
||||
const wrapper = mount(VTable, { props: { headers: testHeaders, items: testItems } });
|
||||
const rows = wrapper.findAll('tbody tr');
|
||||
expect(rows.length).toBe(testItems.length);
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const cells = row.findAll('td');
|
||||
expect(cells.length).toBe(testHeaders.length);
|
||||
testHeaders.forEach((header, colIndex) => {
|
||||
expect(cells[colIndex].text()).toBe(String(testItems[rowIndex][header.key]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('applies stickyHeader class to thead', () => {
|
||||
const wrapper = mount(VTable, { props: { headers: [], items: [], stickyHeader: true } });
|
||||
expect(wrapper.find('thead').classes()).toContain('sticky-header');
|
||||
});
|
||||
|
||||
it('applies stickyFooter class to tfoot', () => {
|
||||
const wrapper = mount(VTable, {
|
||||
props: { headers: [], items: [], stickyFooter: true },
|
||||
slots: { footer: '<tr><td>Footer</td></tr>' },
|
||||
});
|
||||
expect(wrapper.find('tfoot').classes()).toContain('sticky-footer');
|
||||
});
|
||||
|
||||
it('does not render tfoot if no footer slot', () => {
|
||||
const wrapper = mount(VTable, { props: { headers: [], items: [] } });
|
||||
expect(wrapper.find('tfoot').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders custom header slot content', () => {
|
||||
const wrapper = mount(VTable, {
|
||||
props: { headers: [{ key: 'name', label: 'Name' }], items: [] },
|
||||
slots: { 'header.name': '<div class="custom-header-slot">Custom Name Header</div>' },
|
||||
});
|
||||
const headerCell = wrapper.find('thead th');
|
||||
expect(headerCell.find('.custom-header-slot').exists()).toBe(true);
|
||||
expect(headerCell.text()).toBe('Custom Name Header');
|
||||
});
|
||||
|
||||
it('renders custom item cell slot content', () => {
|
||||
const wrapper = mount(VTable, {
|
||||
props: { headers: [{ key: 'name', label: 'Name' }], items: [{ name: 'Alice' }] },
|
||||
slots: { 'item.name': '<template #item.name="{ value }"><strong>{{ value.toUpperCase() }}</strong></template>' },
|
||||
});
|
||||
const cell = wrapper.find('tbody td');
|
||||
expect(cell.find('strong').exists()).toBe(true);
|
||||
expect(cell.text()).toBe('ALICE');
|
||||
});
|
||||
|
||||
it('renders custom full item row slot content', () => {
|
||||
const wrapper = mount(VTable, {
|
||||
props: { headers: testHeaders, items: [testItems[0]] },
|
||||
slots: {
|
||||
'item': '<template #item="{ item, rowIndex }"><tr class="custom-row"><td :colspan="3">Custom Row {{ rowIndex }}: {{ item.name }}</td></tr></template>'
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('tbody tr.custom-row').exists()).toBe(true);
|
||||
expect(wrapper.find('tbody td').text()).toBe('Custom Row 0: Alice');
|
||||
});
|
||||
|
||||
|
||||
it('renders empty-state slot when items array is empty', () => {
|
||||
const emptyStateContent = '<div>No items available.</div>';
|
||||
const wrapper = mount(VTable, {
|
||||
props: { headers: testHeaders, items: [] },
|
||||
slots: { 'empty-state': emptyStateContent },
|
||||
});
|
||||
const emptyRow = wrapper.find('tbody tr');
|
||||
expect(emptyRow.exists()).toBe(true);
|
||||
const cell = emptyRow.find('td');
|
||||
expect(cell.exists()).toBe(true);
|
||||
expect(cell.attributes('colspan')).toBe(String(testHeaders.length));
|
||||
expect(cell.html()).toContain(emptyStateContent);
|
||||
});
|
||||
|
||||
it('renders empty-state slot with colspan 1 if headers are also empty', () => {
|
||||
const wrapper = mount(VTable, {
|
||||
props: { headers: [], items: [] }, // No headers
|
||||
slots: { 'empty-state': '<span>Empty</span>' },
|
||||
});
|
||||
const cell = wrapper.find('tbody td');
|
||||
expect(cell.attributes('colspan')).toBe('1');
|
||||
});
|
||||
|
||||
|
||||
it('renders caption from prop', () => {
|
||||
const captionText = 'My Table Caption';
|
||||
const wrapper = mount(VTable, { props: { headers: [], items: [], caption: captionText } });
|
||||
const captionElement = wrapper.find('caption');
|
||||
expect(captionElement.exists()).toBe(true);
|
||||
expect(captionElement.text()).toBe(captionText);
|
||||
});
|
||||
|
||||
it('renders caption from slot (overrides prop)', () => {
|
||||
const slotCaption = '<em>Slot Caption</em>';
|
||||
const wrapper = mount(VTable, {
|
||||
props: { headers: [], items: [], caption: 'Prop Caption Ignored' },
|
||||
slots: { caption: slotCaption },
|
||||
});
|
||||
const captionElement = wrapper.find('caption');
|
||||
expect(captionElement.html()).toContain(slotCaption);
|
||||
});
|
||||
|
||||
it('does not render caption if no prop and no slot', () => {
|
||||
const wrapper = mount(VTable, { props: { headers: [], items: [] } });
|
||||
expect(wrapper.find('caption').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('applies tableClass to table element', () => {
|
||||
const customClass = 'my-custom-table-class';
|
||||
const wrapper = mount(VTable, { props: { headers: [], items: [], tableClass: customClass } });
|
||||
expect(wrapper.find('table.table').classes()).toContain(customClass);
|
||||
});
|
||||
|
||||
it('applies headerClass to th element', () => {
|
||||
const headerWithClass = [{ key: 'id', label: 'ID', headerClass: 'custom-th-class' }];
|
||||
const wrapper = mount(VTable, { props: { headers: headerWithClass, items: [] } });
|
||||
expect(wrapper.find('thead th').classes()).toContain('custom-th-class');
|
||||
});
|
||||
|
||||
it('applies cellClass to td element', () => {
|
||||
const headerWithCellClass = [{ key: 'name', label: 'Name', cellClass: 'custom-td-class' }];
|
||||
const itemsForCellClass = [{ name: 'Test' }];
|
||||
const wrapper = mount(VTable, { props: { headers: headerWithCellClass, items: itemsForCellClass } });
|
||||
expect(wrapper.find('tbody td').classes()).toContain('custom-td-class');
|
||||
});
|
||||
|
||||
it('renders an empty tbody if items is empty and no empty-state slot', () => {
|
||||
const wrapper = mount(VTable, { props: { headers: testHeaders, items: [] } });
|
||||
const tbody = wrapper.find('tbody');
|
||||
expect(tbody.exists()).toBe(true);
|
||||
expect(tbody.findAll('tr').length).toBe(0); // No rows
|
||||
});
|
||||
});
|
@ -1,229 +0,0 @@
|
||||
import VTable from './VTable.vue';
|
||||
import VBadge from './VBadge.vue'; // For custom cell rendering example
|
||||
import VAvatar from './VAvatar.vue'; // For custom cell rendering
|
||||
import VIcon from './VIcon.vue'; // For custom header rendering
|
||||
import VButton from './VButton.vue'; // For empty state actions
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const meta: Meta<typeof VTable> = {
|
||||
title: 'Valerie/VTable',
|
||||
component: VTable,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
headers: { control: 'object', description: 'Array of header objects ({ key, label, ... }).' },
|
||||
items: { control: 'object', description: 'Array of item objects for rows.' },
|
||||
stickyHeader: { control: 'boolean' },
|
||||
stickyFooter: { control: 'boolean' },
|
||||
tableClass: { control: 'text', description: 'Custom class(es) for the table element.' },
|
||||
caption: { control: 'text', description: 'Caption text for the table.' },
|
||||
// Slots are demonstrated in stories
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'A table component for displaying tabular data. Supports custom rendering for headers and cells, sticky header/footer, empty state, and more.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof VTable>;
|
||||
|
||||
const sampleHeaders = [
|
||||
{ key: 'id', label: 'ID', sortable: true, headerClass: 'id-header', cellClass: 'id-cell' },
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email Address' },
|
||||
{ key: 'status', label: 'Status', cellClass: 'status-cell-shared' },
|
||||
{ key: 'actions', label: 'Actions', sortable: false },
|
||||
];
|
||||
|
||||
const sampleItems = [
|
||||
{ id: 1, name: 'Alice Wonderland', email: 'alice@example.com', status: 'Active', role: 'Admin' },
|
||||
{ id: 2, name: 'Bob The Builder', email: 'bob@example.com', status: 'Inactive', role: 'Editor' },
|
||||
{ id: 3, name: 'Charlie Brown', email: 'charlie@example.com', status: 'Pending', role: 'Viewer' },
|
||||
{ id: 4, name: 'Diana Prince', email: 'diana@example.com', status: 'Active', role: 'Admin' },
|
||||
];
|
||||
|
||||
export const BasicTable: Story = {
|
||||
args: {
|
||||
headers: sampleHeaders.filter(h => h.key !== 'actions'), // Exclude actions for basic
|
||||
items: sampleItems,
|
||||
caption: 'User information list.',
|
||||
},
|
||||
};
|
||||
|
||||
export const StickyHeader: Story = {
|
||||
args: {
|
||||
...BasicTable.args,
|
||||
stickyHeader: true,
|
||||
items: [...sampleItems, ...sampleItems, ...sampleItems], // More items to make scroll visible
|
||||
},
|
||||
// Decorator to provide a scrollable container for the story
|
||||
decorators: [() => ({ template: '<div style="height: 200px; overflow-y: auto; border: 1px solid #ccc;"><story/></div>' })],
|
||||
};
|
||||
|
||||
export const CustomCellRendering: Story = {
|
||||
render: (args) => ({
|
||||
components: { VTable, VBadge, VAvatar },
|
||||
setup() { return { args }; },
|
||||
template: `
|
||||
<VTable :headers="args.headers" :items="args.items" :caption="args.caption">
|
||||
<template #item.name="{ item }">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<VAvatar :initials="item.name.substring(0,1)" size="sm" style="width: 24px; height: 24px; font-size: 0.7em; margin-right: 8px;" />
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.status="{ value }">
|
||||
<VBadge
|
||||
:text="value"
|
||||
:variant="value === 'Active' ? 'success' : (value === 'Inactive' ? 'neutral' : 'pending')"
|
||||
/>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<VButton size="sm" variant="primary" @click="() => alert('Editing item ' + item.id)">Edit</VButton>
|
||||
</template>
|
||||
</VTable>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
headers: sampleHeaders,
|
||||
items: sampleItems,
|
||||
caption: 'Table with custom cell rendering for Status and Actions.',
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomHeaderRendering: Story = {
|
||||
render: (args) => ({
|
||||
components: { VTable, VIcon },
|
||||
setup() { return { args }; },
|
||||
template: `
|
||||
<VTable :headers="args.headers" :items="args.items">
|
||||
<template #header.name="{ header }">
|
||||
{{ header.label }} <VIcon name="alert" size="sm" style="color: blue;" />
|
||||
</template>
|
||||
<template #header.email="{ header }">
|
||||
<i>{{ header.label }} (italic)</i>
|
||||
</template>
|
||||
</VTable>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
headers: sampleHeaders.filter(h => h.key !== 'actions'),
|
||||
items: sampleItems.slice(0, 2),
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyStateTable: Story = {
|
||||
render: (args) => ({
|
||||
components: { VTable, VButton, VIcon },
|
||||
setup() { return { args }; },
|
||||
template: `
|
||||
<VTable :headers="args.headers" :items="args.items">
|
||||
<template #empty-state>
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<VIcon name="search" size="lg" style="margin-bottom: 1rem; color: #6c757d;" />
|
||||
<h3>No Users Found</h3>
|
||||
<p>There are no users matching your current criteria. Try adjusting your search or filters.</p>
|
||||
<VButton variant="primary" @click="() => alert('Add User clicked')">Add New User</VButton>
|
||||
</div>
|
||||
</template>
|
||||
</VTable>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
headers: sampleHeaders,
|
||||
items: [], // Empty items array
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFooter: Story = {
|
||||
render: (args) => ({
|
||||
components: { VTable },
|
||||
setup() { return { args }; },
|
||||
template: `
|
||||
<VTable :headers="args.headers" :items="args.items" :stickyFooter="args.stickyFooter">
|
||||
<template #footer>
|
||||
<tr>
|
||||
<td :colspan="args.headers.length -1" style="text-align: right; font-weight: bold;">Total Users:</td>
|
||||
<td style="font-weight: bold;">{{ args.items.length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td :colspan="args.headers.length" style="text-align: center; font-size: 0.9em;">
|
||||
End of user list.
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</VTable>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
headers: sampleHeaders.filter(h => h.key !== 'actions' && h.key !== 'email'), // Simplified headers for footer example
|
||||
items: sampleItems,
|
||||
stickyFooter: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const StickyHeaderAndFooter: Story = {
|
||||
...WithFooter, // Reuses render from WithFooter
|
||||
args: {
|
||||
...WithFooter.args,
|
||||
stickyHeader: true,
|
||||
stickyFooter: true,
|
||||
items: [...sampleItems, ...sampleItems, ...sampleItems], // More items for scrolling
|
||||
},
|
||||
decorators: [() => ({ template: '<div style="height: 250px; overflow-y: auto; border: 1px solid #ccc;"><story/></div>' })],
|
||||
};
|
||||
|
||||
|
||||
export const WithCustomTableAndCellClasses: Story = {
|
||||
args: {
|
||||
headers: [
|
||||
{ key: 'id', label: 'ID', headerClass: 'text-danger font-weight-bold', cellClass: 'text-muted' },
|
||||
{ key: 'name', label: 'Name', headerClass: ['bg-light-blue', 'p-2'], cellClass: (item) => ({ 'text-success': item.status === 'Active' }) },
|
||||
{ key: 'email', label: 'Email' },
|
||||
],
|
||||
items: sampleItems.slice(0,2).map(item => ({...item, headerClass:'should-not-apply-here'})), // added dummy prop to item
|
||||
tableClass: 'table-striped table-hover custom-global-table-class', // Example global/utility classes
|
||||
caption: 'Table with custom classes applied via props.',
|
||||
},
|
||||
// For this story to fully work, the specified custom classes (e.g., text-danger, bg-light-blue)
|
||||
// would need to be defined globally or in valerie-ui.scss.
|
||||
// Storybook will render the classes, but their visual effect depends on CSS definitions.
|
||||
parameters: {
|
||||
docs: {
|
||||
description: { story: 'Demonstrates applying custom CSS classes to the table, header cells, and body cells using `tableClass`, `headerClass`, and `cellClass` props. The actual styling effect depends on these classes being defined in your CSS.'}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const FullRowSlot: Story = {
|
||||
render: (args) => ({
|
||||
components: { VTable, VBadge },
|
||||
setup() { return { args }; },
|
||||
template: `
|
||||
<VTable :headers="args.headers" :items="args.items">
|
||||
<template #item="{ item, rowIndex }">
|
||||
<tr :class="rowIndex % 2 === 0 ? 'bg-light-gray' : 'bg-white'">
|
||||
<td colspan="1" style="font-weight:bold;">ROW {{ rowIndex + 1 }}</td>
|
||||
<td colspan="2">
|
||||
<strong>{{ item.name }}</strong> ({{ item.email }}) - Role: {{item.role}}
|
||||
</td>
|
||||
<td><VBadge :text="item.status" :variant="item.status === 'Active' ? 'success' : 'neutral'" /></td>
|
||||
</tr>
|
||||
</template>
|
||||
</VTable>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
headers: sampleHeaders.filter(h => h.key !== 'actions'), // Adjust headers as the slot takes full control
|
||||
items: sampleItems,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {story: "Demonstrates using the `item` slot to take full control of row rendering. The `headers` prop is still used for `<thead>` generation, but `<tbody>` rows are completely defined by this slot."}
|
||||
}
|
||||
}
|
||||
};
|
@ -1,170 +0,0 @@
|
||||
<template>
|
||||
<div class="table-container">
|
||||
<table class="table" :class="tableClass">
|
||||
<caption v-if="$slots.caption || caption">
|
||||
<slot name="caption">{{ caption }}</slot>
|
||||
</caption>
|
||||
<thead :class="{ 'sticky-header': stickyHeader }">
|
||||
<tr>
|
||||
<th
|
||||
v-for="header in headers"
|
||||
:key="header.key"
|
||||
:class="header.headerClass"
|
||||
scope="col"
|
||||
>
|
||||
<slot :name="`header.${header.key}`" :header="header">
|
||||
{{ header.label }}
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="items.length === 0 && $slots['empty-state']">
|
||||
<tr>
|
||||
<td :colspan="headers.length || 1"> {/* Fallback colspan if headers is empty */}
|
||||
<slot name="empty-state"></slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="(item, rowIndex) in items" :key="rowIndex">
|
||||
<slot name="item" :item="item" :rowIndex="rowIndex">
|
||||
<tr>
|
||||
<td
|
||||
v-for="header in headers"
|
||||
:key="header.key"
|
||||
:class="header.cellClass"
|
||||
>
|
||||
<slot :name="`item.${header.key}`" :item="item" :value="item[header.key]" :rowIndex="rowIndex">
|
||||
{{ item[header.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</slot>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
<tfoot v-if="$slots.footer" :class="{ 'sticky-footer': stickyFooter }">
|
||||
<slot name="footer"></slot>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, PropType } from 'vue';
|
||||
|
||||
interface TableHeader {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
headerClass?: string | string[] | Record<string, boolean>;
|
||||
cellClass?: string | string[] | Record<string, boolean>;
|
||||
}
|
||||
|
||||
// Using defineProps with generic type for items is complex.
|
||||
// Using `any` for items for now, can be refined if specific item structure is enforced.
|
||||
const props = defineProps({
|
||||
headers: {
|
||||
type: Array as PropType<TableHeader[]>,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<any[]>,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
stickyHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
stickyFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tableClass: {
|
||||
type: [String, Array, Object] as PropType<string | string[] | Record<string, boolean>>,
|
||||
default: '',
|
||||
},
|
||||
caption: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// No specific reactive logic needed in setup for this version,
|
||||
// but setup script is used for type imports and defineProps.
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// These styles should align with valerie-ui.scss or be defined here.
|
||||
// Assuming standard table styling from a global scope or valerie-ui.scss.
|
||||
// For demonstration, some basic table styles are included.
|
||||
|
||||
.table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto; // Enable horizontal scrolling if table is wider than container
|
||||
// For sticky header/footer to work correctly, the container might need a defined height
|
||||
// or be within a scrollable viewport.
|
||||
// max-height: 500px; // Example max height for sticky demo
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse; // Standard table practice
|
||||
// Example base styling, should come from valerie-ui.scss ideally
|
||||
font-size: 0.9rem;
|
||||
color: var(--table-text-color, #333);
|
||||
background-color: var(--table-bg-color, #fff);
|
||||
|
||||
caption {
|
||||
padding: 0.5em 0;
|
||||
caption-side: bottom; // Or top, depending on preference/standard
|
||||
font-size: 0.85em;
|
||||
color: var(--table-caption-color, #666);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75em 1em; // Example padding
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--table-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-weight: 600; // Bolder for header cells
|
||||
background-color: var(--table-header-bg, #f8f9fa);
|
||||
border-bottom-width: 2px; // Thicker border under header
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--table-row-hover-bg, #f1f3f5);
|
||||
}
|
||||
|
||||
// Sticky styles
|
||||
.sticky-header th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10; // Ensure header is above body content during scroll
|
||||
background-color: var(--table-header-sticky-bg, #f0f2f5); // Might need distinct bg
|
||||
}
|
||||
|
||||
.sticky-footer { // Applied to tfoot
|
||||
td, th { // Assuming footer might contain th or td
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10; // Ensure footer is above body
|
||||
background-color: var(--table-footer-sticky-bg, #f0f2f5);
|
||||
}
|
||||
}
|
||||
// If both stickyHeader and stickyFooter are used, ensure z-indexes are managed.
|
||||
// Also, for sticky to work on thead/tfoot, the table-container needs to be the scrollable element,
|
||||
// or the window itself if the table is large enough.
|
||||
}
|
||||
|
||||
// Example of custom classes from props (these would be defined by user)
|
||||
// .custom-header-class { background-color: lightblue; }
|
||||
// .custom-cell-class { font-style: italic; }
|
||||
// .custom-table-class { border: 2px solid blue; }
|
||||
</style>
|
@ -1,117 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
@ -1,173 +0,0 @@
|
||||
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
|
||||
},
|
||||
},
|
||||
};
|
@ -1,131 +0,0 @@
|
||||
<template>
|
||||
<textarea :id="id" :value="modelValue" :placeholder="placeholder" :disabled="disabled" :required="required"
|
||||
:rows="rows" :class="textareaClasses" :aria-invalid="error ? 'true' : 'false'" @input="onInput"></textarea>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, type 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>
|
@ -1,109 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
@ -1,138 +0,0 @@
|
||||
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.' },
|
||||
},
|
||||
},
|
||||
};
|
@ -1,184 +0,0 @@
|
||||
<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>
|
@ -1,103 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import VTooltip from './VTooltip.vue';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('VTooltip.vue', () => {
|
||||
it('renders default slot content (trigger)', () => {
|
||||
const triggerContent = '<button>Hover Me</button>';
|
||||
const wrapper = mount(VTooltip, {
|
||||
props: { text: 'Tooltip text' },
|
||||
slots: { default: triggerContent },
|
||||
});
|
||||
const trigger = wrapper.find('.tooltip-trigger');
|
||||
expect(trigger.exists()).toBe(true);
|
||||
expect(trigger.html()).toContain(triggerContent);
|
||||
});
|
||||
|
||||
it('renders tooltip text with correct text prop', () => {
|
||||
const tipText = 'This is the tooltip content.';
|
||||
const wrapper = mount(VTooltip, {
|
||||
props: { text: tipText },
|
||||
slots: { default: '<span>Trigger</span>' },
|
||||
});
|
||||
const tooltipTextElement = wrapper.find('.tooltip-text');
|
||||
expect(tooltipTextElement.exists()).toBe(true);
|
||||
expect(tooltipTextElement.text()).toBe(tipText);
|
||||
});
|
||||
|
||||
it('applies position-specific class to the root wrapper', () => {
|
||||
const wrapperTop = mount(VTooltip, {
|
||||
props: { text: 'Hi', position: 'top' },
|
||||
slots: { default: 'Trg' },
|
||||
});
|
||||
expect(wrapperTop.find('.tooltip-wrapper').classes()).toContain('tooltip-top');
|
||||
|
||||
const wrapperBottom = mount(VTooltip, {
|
||||
props: { text: 'Hi', position: 'bottom' },
|
||||
slots: { default: 'Trg' },
|
||||
});
|
||||
expect(wrapperBottom.find('.tooltip-wrapper').classes()).toContain('tooltip-bottom');
|
||||
|
||||
const wrapperLeft = mount(VTooltip, {
|
||||
props: { text: 'Hi', position: 'left' },
|
||||
slots: { default: 'Trg' },
|
||||
});
|
||||
expect(wrapperLeft.find('.tooltip-wrapper').classes()).toContain('tooltip-left');
|
||||
|
||||
const wrapperRight = mount(VTooltip, {
|
||||
props: { text: 'Hi', position: 'right' },
|
||||
slots: { default: 'Trg' },
|
||||
});
|
||||
expect(wrapperRight.find('.tooltip-wrapper').classes()).toContain('tooltip-right');
|
||||
});
|
||||
|
||||
it('defaults to "top" position if not specified', () => {
|
||||
const wrapper = mount(VTooltip, {
|
||||
props: { text: 'Default position' },
|
||||
slots: { default: 'Trigger' },
|
||||
});
|
||||
expect(wrapper.find('.tooltip-wrapper').classes()).toContain('tooltip-top');
|
||||
});
|
||||
|
||||
|
||||
it('applies provided id to tooltip-text and aria-describedby to trigger', () => {
|
||||
const customId = 'my-tooltip-123';
|
||||
const wrapper = mount(VTooltip, {
|
||||
props: { text: 'With ID', id: customId },
|
||||
slots: { default: 'Trigger Element' },
|
||||
});
|
||||
expect(wrapper.find('.tooltip-text').attributes('id')).toBe(customId);
|
||||
expect(wrapper.find('.tooltip-trigger').attributes('aria-describedby')).toBe(customId);
|
||||
});
|
||||
|
||||
it('generates a unique id for tooltip-text if id prop is not provided', () => {
|
||||
const wrapper = mount(VTooltip, {
|
||||
props: { text: 'Auto ID' },
|
||||
slots: { default: 'Trigger' },
|
||||
});
|
||||
const tooltipTextElement = wrapper.find('.tooltip-text');
|
||||
const generatedId = tooltipTextElement.attributes('id');
|
||||
expect(generatedId).toMatch(/^v-tooltip-/);
|
||||
expect(wrapper.find('.tooltip-trigger').attributes('aria-describedby')).toBe(generatedId);
|
||||
});
|
||||
|
||||
it('tooltip-text has role="tooltip"', () => {
|
||||
const wrapper = mount(VTooltip, {
|
||||
props: { text: 'Role test' },
|
||||
slots: { default: 'Trigger' },
|
||||
});
|
||||
expect(wrapper.find('.tooltip-text').attributes('role')).toBe('tooltip');
|
||||
});
|
||||
|
||||
it('tooltip-trigger has tabindex="0"', () => {
|
||||
const wrapper = mount(VTooltip, {
|
||||
props: { text: 'Focus test' },
|
||||
slots: { default: '<span>Non-focusable by default</span>' },
|
||||
});
|
||||
expect(wrapper.find('.tooltip-trigger').attributes('tabindex')).toBe('0');
|
||||
});
|
||||
|
||||
// Note: Testing CSS-driven visibility on hover/focus is generally outside the scope of JSDOM unit tests.
|
||||
// These tests would typically be done in an E2E testing environment with a real browser.
|
||||
// We can, however, test that the structure and attributes that enable this CSS are present.
|
||||
});
|
@ -1,120 +0,0 @@
|
||||
import VTooltip from './VTooltip.vue';
|
||||
import VButton from './VButton.vue'; // Example trigger
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
const meta: Meta<typeof VTooltip> = {
|
||||
title: 'Valerie/VTooltip',
|
||||
component: VTooltip,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
text: { control: 'text', description: 'Tooltip text content.' },
|
||||
position: {
|
||||
control: 'select',
|
||||
options: ['top', 'bottom', 'left', 'right'],
|
||||
description: 'Tooltip position relative to the trigger.',
|
||||
},
|
||||
id: { control: 'text', description: 'Optional ID for the tooltip text element (ARIA).' },
|
||||
// Slot
|
||||
default: { description: 'The trigger element for the tooltip.', table: { disable: true } },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'A tooltip component that displays informational text when a trigger element is hovered or focused. Uses CSS for positioning and visibility.',
|
||||
},
|
||||
},
|
||||
// Adding some layout to center stories and provide space for tooltips
|
||||
layout: 'centered',
|
||||
},
|
||||
// Decorator to add some margin around stories so tooltips don't get cut off by viewport
|
||||
decorators: [() => ({ template: '<div style="padding: 50px;"><story/></div>' })],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof VTooltip>;
|
||||
|
||||
export const Top: Story = {
|
||||
render: (args) => ({
|
||||
components: { VTooltip, VButton },
|
||||
setup() { return { args }; },
|
||||
template: `
|
||||
<VTooltip :text="args.text" :position="args.position" :id="args.id">
|
||||
<VButton>Hover or Focus Me (Top)</VButton>
|
||||
</VTooltip>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
text: 'This is a tooltip displayed on top.',
|
||||
position: 'top',
|
||||
id: 'tooltip-top-example',
|
||||
},
|
||||
};
|
||||
|
||||
export const Bottom: Story = {
|
||||
...Top, // Reuses render function from Top story
|
||||
args: {
|
||||
text: 'Tooltip shown at the bottom.',
|
||||
position: 'bottom',
|
||||
id: 'tooltip-bottom-example',
|
||||
},
|
||||
};
|
||||
|
||||
export const Left: Story = {
|
||||
...Top,
|
||||
args: {
|
||||
text: 'This appears to the left.',
|
||||
position: 'left',
|
||||
id: 'tooltip-left-example',
|
||||
},
|
||||
};
|
||||
|
||||
export const Right: Story = {
|
||||
...Top,
|
||||
args: {
|
||||
text: 'And this one to the right!',
|
||||
position: 'right',
|
||||
id: 'tooltip-right-example',
|
||||
},
|
||||
};
|
||||
|
||||
export const OnPlainText: Story = {
|
||||
render: (args) => ({
|
||||
components: { VTooltip },
|
||||
setup() { return { args }; },
|
||||
template: `
|
||||
<p>
|
||||
Some text here, and
|
||||
<VTooltip :text="args.text" :position="args.position">
|
||||
<span style="text-decoration: underline; color: blue;">this part has a tooltip</span>
|
||||
</VTooltip>
|
||||
which shows up on hover or focus.
|
||||
</p>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
text: 'Tooltip on a span of text!',
|
||||
position: 'top',
|
||||
},
|
||||
};
|
||||
|
||||
export const LongTextTooltip: Story = {
|
||||
...Top,
|
||||
args: {
|
||||
text: 'This is a much longer tooltip text to see how it behaves. It should remain on a single line by default due to white-space: nowrap. If multi-line is needed, CSS for .tooltip-text would need adjustment (e.g., white-space: normal, width/max-width).',
|
||||
position: 'bottom',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: { story: 'Demonstrates a tooltip with a longer text content. Default styling keeps it on one line.'}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const WithSpecificId: Story = {
|
||||
...Top,
|
||||
args: {
|
||||
text: 'This tooltip has a specific ID for its text element.',
|
||||
position: 'top',
|
||||
id: 'my-custom-tooltip-id-123',
|
||||
},
|
||||
};
|
@ -1,151 +0,0 @@
|
||||
<template>
|
||||
<div class="tooltip-wrapper" :class="['tooltip-' + position]">
|
||||
<span class="tooltip-trigger" tabindex="0" :aria-describedby="tooltipId">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<span class="tooltip-text" role="tooltip" :id="tooltipId">
|
||||
{{ text }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
position: {
|
||||
type: String, // 'top', 'bottom', 'left', 'right'
|
||||
default: 'top',
|
||||
validator: (value: string) => ['top', 'bottom', 'left', 'right'].includes(value),
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const tooltipId = computed(() => {
|
||||
return props.id || `v-tooltip-${Math.random().toString(36).substring(2, 9)}`;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// These styles should align with valerie-ui.scss's .tooltip definition.
|
||||
// For this component, we'll define them here.
|
||||
// A .tooltip-wrapper is used instead of .tooltip directly on the trigger's parent
|
||||
// to give more flexibility if the trigger is an inline element.
|
||||
.tooltip-wrapper {
|
||||
position: relative;
|
||||
display: inline-block; // Or 'block' or 'inline-flex' depending on how it should behave in layout
|
||||
}
|
||||
|
||||
.tooltip-trigger {
|
||||
// display: inline-block; // Ensure it can have dimensions if it's an inline element like <span>
|
||||
cursor: help; // Or default, depending on trigger type
|
||||
// Ensure trigger is focusable for keyboard accessibility if it's not inherently focusable (e.g. a span)
|
||||
// tabindex="0" is added in the template.
|
||||
&:focus {
|
||||
outline: none; // Or a custom focus style if desired for the trigger itself
|
||||
// When trigger is focused, the tooltip-text should become visible (handled by CSS below)
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-text {
|
||||
position: absolute;
|
||||
z-index: 1070; // High z-index to appear above other elements
|
||||
display: block;
|
||||
padding: 0.4em 0.8em;
|
||||
font-size: 0.875rem; // Slightly smaller font for tooltip
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: nowrap; // Tooltips are usually single-line, can be changed if multi-line is needed
|
||||
color: var(--tooltip-text-color, #fff); // Text color
|
||||
background-color: var(--tooltip-bg-color, #343a40); // Background color (dark gray/black)
|
||||
border-radius: 0.25rem; // Rounded corners
|
||||
|
||||
// Visibility: hidden by default, shown on hover/focus of the wrapper/trigger
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
|
||||
|
||||
// Arrow (pseudo-element)
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-width: 5px; // Size of the arrow
|
||||
border-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
// Show tooltip on hover or focus of the wrapper (or trigger)
|
||||
.tooltip-wrapper:hover .tooltip-text,
|
||||
.tooltip-wrapper:focus-within .tooltip-text, // focus-within for keyboard nav on trigger
|
||||
.tooltip-trigger:focus + .tooltip-text { // If trigger is focused directly
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
// Positioning
|
||||
// TOP
|
||||
.tooltip-top .tooltip-text {
|
||||
bottom: 100%; // Position above the trigger
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-6px); // Center it and add margin from arrow
|
||||
|
||||
&::after {
|
||||
top: 100%; // Arrow at the bottom of the tooltip text pointing down
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-color: var(--tooltip-bg-color, #343a40) transparent transparent transparent; // Arrow color
|
||||
}
|
||||
}
|
||||
|
||||
// BOTTOM
|
||||
.tooltip-bottom .tooltip-text {
|
||||
top: 100%; // Position below the trigger
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(6px); // Center it and add margin
|
||||
|
||||
&::after {
|
||||
bottom: 100%; // Arrow at the top of the tooltip text pointing up
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-color: transparent transparent var(--tooltip-bg-color, #343a40) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// LEFT
|
||||
.tooltip-left .tooltip-text {
|
||||
top: 50%;
|
||||
right: 100%; // Position to the left of the trigger
|
||||
transform: translateY(-50%) translateX(-6px); // Center it and add margin
|
||||
|
||||
&::after {
|
||||
top: 50%;
|
||||
left: 100%; // Arrow at the right of the tooltip text pointing right
|
||||
transform: translateY(-50%);
|
||||
border-color: transparent transparent transparent var(--tooltip-bg-color, #343a40);
|
||||
}
|
||||
}
|
||||
|
||||
// RIGHT
|
||||
.tooltip-right .tooltip-text {
|
||||
top: 50%;
|
||||
left: 100%; // Position to the right of the trigger
|
||||
transform: translateY(-50%) translateX(6px); // Center it and add margin
|
||||
|
||||
&::after {
|
||||
top: 50%;
|
||||
right: 100%; // Arrow at the left of the tooltip text pointing left
|
||||
transform: translateY(-50%);
|
||||
border-color: transparent var(--tooltip-bg-color, #343a40) transparent transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,206 +0,0 @@
|
||||
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
|
||||
},
|
||||
};
|
@ -1,108 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
@ -1,99 +0,0 @@
|
||||
<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>
|
@ -1,25 +0,0 @@
|
||||
<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>
|
@ -1,35 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
@ -1,72 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
@ -1,55 +0,0 @@
|
||||
<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>
|
@ -1,25 +0,0 @@
|
||||
<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>
|
@ -1,135 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
@ -1,94 +0,0 @@
|
||||
<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>
|
@ -1,10 +0,0 @@
|
||||
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');
|
@ -1,57 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
||||
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import Button from './Button.vue';
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
|
||||
const meta = {
|
||||
title: 'Example/Button',
|
||||
component: Button,
|
||||
// This component will have an automatically generated docsPage entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: { control: 'select', options: ['small', 'medium', 'large'] },
|
||||
backgroundColor: { control: 'color' },
|
||||
},
|
||||
args: {
|
||||
primary: false,
|
||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||
onClick: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
/*
|
||||
*👇 Render functions are a framework specific feature to allow you control on how the component renders.
|
||||
* See https://storybook.js.org/docs/api/csf
|
||||
* to learn how to use render functions.
|
||||
*/
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
primary: true,
|
||||
label: 'Button',
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
primary: false,
|
||||
label: 'Button',
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
label: 'Button',
|
||||
size: 'large',
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
label: 'Button',
|
||||
size: 'small',
|
||||
},
|
||||
};
|
@ -1,50 +0,0 @@
|
||||
<template>
|
||||
<button type="button" :class="classes" @click="onClick" :style="style">{{ label }}</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import './button.css';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/**
|
||||
* The label of the button
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* primary or secondary button
|
||||
*/
|
||||
primary?: boolean;
|
||||
/**
|
||||
* size of the button
|
||||
*/
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
/**
|
||||
* background color of the button
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
}>(),
|
||||
{ primary: false }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', id: number): void;
|
||||
}>();
|
||||
|
||||
const classes = computed(() => ({
|
||||
'storybook-button': true,
|
||||
'storybook-button--primary': props.primary,
|
||||
'storybook-button--secondary': !props.primary,
|
||||
[`storybook-button--${props.size || 'medium'}`]: true,
|
||||
}));
|
||||
|
||||
const style = computed(() => ({
|
||||
backgroundColor: props.backgroundColor,
|
||||
}));
|
||||
|
||||
const onClick = () => {
|
||||
emit('click', 1);
|
||||
};
|
||||
</script>
|
@ -1,364 +0,0 @@
|
||||
import { Meta } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import Github from "./assets/github.svg";
|
||||
import Discord from "./assets/discord.svg";
|
||||
import Youtube from "./assets/youtube.svg";
|
||||
import Tutorials from "./assets/tutorials.svg";
|
||||
import Styling from "./assets/styling.png";
|
||||
import Context from "./assets/context.png";
|
||||
import Assets from "./assets/assets.png";
|
||||
import Docs from "./assets/docs.png";
|
||||
import Share from "./assets/share.png";
|
||||
import FigmaPlugin from "./assets/figma-plugin.png";
|
||||
import Testing from "./assets/testing.png";
|
||||
import Accessibility from "./assets/accessibility.png";
|
||||
import Theming from "./assets/theming.png";
|
||||
import AddonLibrary from "./assets/addon-library.png";
|
||||
|
||||
export const RightArrow = () => <svg
|
||||
viewBox="0 0 14 14"
|
||||
width="8px"
|
||||
height="14px"
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
display: 'inline-block',
|
||||
shapeRendering: 'inherit',
|
||||
verticalAlign: 'middle',
|
||||
fill: 'currentColor',
|
||||
'path fill': 'currentColor'
|
||||
}}
|
||||
>
|
||||
<path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" />
|
||||
</svg>
|
||||
|
||||
<Meta title="Configure your project" />
|
||||
|
||||
<div className="sb-container">
|
||||
<div className='sb-section-title'>
|
||||
# Configure your project
|
||||
|
||||
Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community.
|
||||
</div>
|
||||
<div className="sb-section">
|
||||
<div className="sb-section-item">
|
||||
<img
|
||||
src={Styling}
|
||||
alt="A wall of logos representing different styling technologies"
|
||||
/>
|
||||
<h4 className="sb-section-item-heading">Add styling and CSS</h4>
|
||||
<p className="sb-section-item-paragraph">Like with web applications, there are many ways to include CSS within Storybook. Learn more about setting up styling within Storybook.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/configure/styling-and-css/?renderer=vue"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img
|
||||
src={Context}
|
||||
alt="An abstraction representing the composition of data for a component"
|
||||
/>
|
||||
<h4 className="sb-section-item-heading">Provide context and mocking</h4>
|
||||
<p className="sb-section-item-paragraph">Often when a story doesn't render, it's because your component is expecting a specific environment or context (like a theme provider) to be available.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/writing-stories/decorators/?renderer=vue#context-for-mocking"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img src={Assets} alt="A representation of typography and image assets" />
|
||||
<div>
|
||||
<h4 className="sb-section-item-heading">Load assets and resources</h4>
|
||||
<p className="sb-section-item-paragraph">To link static files (like fonts) to your projects and stories, use the
|
||||
`staticDirs` configuration option to specify folders to load when
|
||||
starting Storybook.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/configure/images-and-assets/?renderer=vue"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-container">
|
||||
<div className='sb-section-title'>
|
||||
# Do more with Storybook
|
||||
|
||||
Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs.
|
||||
</div>
|
||||
|
||||
<div className="sb-section">
|
||||
<div className="sb-features-grid">
|
||||
<div className="sb-grid-item">
|
||||
<img src={Docs} alt="A screenshot showing the autodocs tag being set, pointing a docs page being generated" />
|
||||
<h4 className="sb-section-item-heading">Autodocs</h4>
|
||||
<p className="sb-section-item-paragraph">Auto-generate living,
|
||||
interactive reference documentation from your components and stories.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/writing-docs/autodocs/?renderer=vue"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={Share} alt="A browser window showing a Storybook being published to a chromatic.com URL" />
|
||||
<h4 className="sb-section-item-heading">Publish to Chromatic</h4>
|
||||
<p className="sb-section-item-paragraph">Publish your Storybook to review and collaborate with your entire team.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/sharing/publish-storybook/?renderer=vue#publish-storybook-with-chromatic"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={FigmaPlugin} alt="Windows showing the Storybook plugin in Figma" />
|
||||
<h4 className="sb-section-item-heading">Figma Plugin</h4>
|
||||
<p className="sb-section-item-paragraph">Embed your stories into Figma to cross-reference the design and live
|
||||
implementation in one place.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/sharing/design-integrations/?renderer=vue#embed-storybook-in-figma-with-the-plugin"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={Testing} alt="Screenshot of tests passing and failing" />
|
||||
<h4 className="sb-section-item-heading">Testing</h4>
|
||||
<p className="sb-section-item-paragraph">Use stories to test a component in all its variations, no matter how
|
||||
complex.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/writing-tests/?renderer=vue"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={Accessibility} alt="Screenshot of accessibility tests passing and failing" />
|
||||
<h4 className="sb-section-item-heading">Accessibility</h4>
|
||||
<p className="sb-section-item-paragraph">Automatically test your components for a11y issues as you develop.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/writing-tests/accessibility-testing/?renderer=vue"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={Theming} alt="Screenshot of Storybook in light and dark mode" />
|
||||
<h4 className="sb-section-item-heading">Theming</h4>
|
||||
<p className="sb-section-item-paragraph">Theme Storybook's UI to personalize it to your project.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/configure/theming/?renderer=vue"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='sb-addon'>
|
||||
<div className='sb-addon-text'>
|
||||
<h4>Addons</h4>
|
||||
<p className="sb-section-item-paragraph">Integrate your tools with Storybook to connect workflows.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/addons/"
|
||||
target="_blank"
|
||||
>Discover all addons<RightArrow /></a>
|
||||
</div>
|
||||
<div className='sb-addon-img'>
|
||||
<img src={AddonLibrary} alt="Integrate your tools with Storybook to connect workflows." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb-section sb-socials">
|
||||
<div className="sb-section-item">
|
||||
<img src={Github} alt="Github logo" className="sb-explore-image"/>
|
||||
Join our contributors building the future of UI development.
|
||||
|
||||
<a
|
||||
href="https://github.com/storybookjs/storybook"
|
||||
target="_blank"
|
||||
>Star on GitHub<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img src={Discord} alt="Discord logo" className="sb-explore-image"/>
|
||||
<div>
|
||||
Get support and chat with frontend developers.
|
||||
|
||||
<a
|
||||
href="https://discord.gg/storybook"
|
||||
target="_blank"
|
||||
>Join Discord server<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img src={Youtube} alt="Youtube logo" className="sb-explore-image"/>
|
||||
<div>
|
||||
Watch tutorials, feature previews and interviews.
|
||||
|
||||
<a
|
||||
href="https://www.youtube.com/@chromaticui"
|
||||
target="_blank"
|
||||
>Watch on YouTube<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img src={Tutorials} alt="A book" className="sb-explore-image"/>
|
||||
<p>Follow guided walkthroughs on for key workflows.</p>
|
||||
|
||||
<a
|
||||
href="https://storybook.js.org/tutorials/"
|
||||
target="_blank"
|
||||
>Discover tutorials<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{`
|
||||
.sb-container {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.sb-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sb-section-title {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.sb-section a:not(h1 a, h2 a, h3 a) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sb-section-item, .sb-grid-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sb-section-item-heading {
|
||||
padding-top: 20px !important;
|
||||
padding-bottom: 5px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.sb-section-item-paragraph {
|
||||
margin: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.sb-chevron {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.sb-features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: 32px 20px;
|
||||
}
|
||||
|
||||
.sb-socials {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.sb-socials p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sb-explore-image {
|
||||
max-height: 32px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sb-addon {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
background-color: #EEF3F8;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background: #EEF3F8;
|
||||
height: 180px;
|
||||
margin-bottom: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb-addon-text {
|
||||
padding-left: 48px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.sb-addon-text h4 {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.sb-addon-img {
|
||||
position: absolute;
|
||||
left: 345px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 200%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb-addon-img img {
|
||||
width: 650px;
|
||||
transform: rotate(-15deg);
|
||||
margin-left: 40px;
|
||||
margin-top: -72px;
|
||||
box-shadow: 0 0 1px rgba(255, 255, 255, 0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.sb-addon-img {
|
||||
left: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.sb-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sb-features-grid {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
.sb-socials {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.sb-addon {
|
||||
height: 280px;
|
||||
align-items: flex-start;
|
||||
padding-top: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb-addon-text {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.sb-addon-img {
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 130px;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
width: 124%;
|
||||
}
|
||||
|
||||
.sb-addon-img img {
|
||||
width: 1200px;
|
||||
transform: rotate(-12deg);
|
||||
margin-left: 0;
|
||||
margin-top: 48px;
|
||||
margin-bottom: -40px;
|
||||
margin-left: -24px;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
@ -1,49 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
||||
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import MyHeader from './Header.vue';
|
||||
|
||||
const meta = {
|
||||
/* 👇 The title prop is optional.
|
||||
* See https://storybook.js.org/docs/configure/#configure-story-loading
|
||||
* to learn how to generate automatic titles
|
||||
*/
|
||||
title: 'Example/Header',
|
||||
component: MyHeader,
|
||||
render: (args: any) => ({
|
||||
components: { MyHeader },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<my-header :user="args.user" />',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
args: {
|
||||
onLogin: fn(),
|
||||
onLogout: fn(),
|
||||
onCreateAccount: fn(),
|
||||
},
|
||||
// This component will have an automatically generated docsPage entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof MyHeader>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const LoggedIn: Story = {
|
||||
args: {
|
||||
user: {
|
||||
name: 'Jane Doe',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LoggedOut: Story = {
|
||||
args: {
|
||||
user: null,
|
||||
},
|
||||
};
|
@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<header>
|
||||
<div class="storybook-header">
|
||||
<div>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path
|
||||
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
|
||||
fill="#FFF"
|
||||
/>
|
||||
<path
|
||||
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
|
||||
fill="#555AB9"
|
||||
/>
|
||||
<path
|
||||
d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z"
|
||||
fill="#91BAF8"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1>Acme</h1>
|
||||
</div>
|
||||
<div>
|
||||
<span class="welcome" v-if="user"
|
||||
>Welcome, <b>{{ user.name }}</b
|
||||
>!</span
|
||||
>
|
||||
<my-button size="small" @click="$emit('logout')" label="Log out" v-if="user" />
|
||||
<my-button size="small" @click="$emit('login')" label="Log in" v-if="!user" />
|
||||
<my-button
|
||||
primary
|
||||
size="small"
|
||||
@click="$emit('createAccount')"
|
||||
label="Sign up"
|
||||
v-if="!user"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MyButton from './Button.vue';
|
||||
import './header.css';
|
||||
|
||||
defineProps<{ user: { name: string } | null }>();
|
||||
|
||||
defineEmits<{
|
||||
(event: 'createAccount'): void;
|
||||
(event: 'login'): void;
|
||||
(event: 'logout'): void;
|
||||
}>();
|
||||
</script>
|
@ -1,39 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
||||
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
|
||||
import MyPage from './Page.vue';
|
||||
|
||||
const meta = {
|
||||
title: 'Example/Page',
|
||||
component: MyPage,
|
||||
render: () => ({
|
||||
components: { MyPage },
|
||||
template: '<my-page />',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
// This component will have an automatically generated docsPage entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof MyPage>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// More on component testing: https://storybook.js.org/docs/writing-tests/interaction-testing
|
||||
export const LoggedIn: Story = {
|
||||
play: async ({ canvasElement }: any) => {
|
||||
const canvas = within(canvasElement);
|
||||
const loginButton = canvas.getByRole('button', { name: /Log in/i });
|
||||
await expect(loginButton).toBeInTheDocument();
|
||||
await userEvent.click(loginButton);
|
||||
await expect(loginButton).not.toBeInTheDocument();
|
||||
|
||||
const logoutButton = canvas.getByRole('button', { name: /Log out/i });
|
||||
await expect(logoutButton).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const LoggedOut: Story = {};
|
@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<my-header :user="user" @login="onLogin" @logout="onLogout" @create-account="onCreateAccount" />
|
||||
|
||||
<section class="storybook-page">
|
||||
<h2>Pages in Storybook</h2>
|
||||
<p>
|
||||
We recommend building UIs with a
|
||||
<a href="https://componentdriven.org" target="_blank" rel="noopener noreferrer">
|
||||
<strong>component-driven</strong>
|
||||
</a>
|
||||
process starting with atomic components and ending with pages.
|
||||
</p>
|
||||
<p>
|
||||
Render pages with mock data. This makes it easy to build and review page states without
|
||||
needing to navigate to them in your app. Here are some handy patterns for managing page data
|
||||
in Storybook:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Use a higher-level connected component. Storybook helps you compose such data from the
|
||||
"args" of child component stories
|
||||
</li>
|
||||
<li>
|
||||
Assemble data in the page component from your services. You can mock these services out
|
||||
using Storybook.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Get a guided tutorial on component-driven development at
|
||||
<a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer"
|
||||
>Storybook tutorials</a
|
||||
>
|
||||
. Read more in the
|
||||
<a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">docs</a>
|
||||
.
|
||||
</p>
|
||||
<div class="tip-wrapper">
|
||||
<span class="tip">Tip</span>
|
||||
Adjust the width of the canvas with the
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path
|
||||
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
|
||||
id="a"
|
||||
fill="#999"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
Viewports addon in the toolbar
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import MyHeader from './Header.vue';
|
||||
import './page.css';
|
||||
|
||||
const user = ref<{ name: string } | null>(null);
|
||||
|
||||
const onLogin = () => {
|
||||
user.value = { name: 'Jane Doe' };
|
||||
};
|
||||
const onLogout = () => {
|
||||
user.value = null;
|
||||
};
|
||||
const onCreateAccount = () => {
|
||||
user.value = { name: 'Jane Doe' };
|
||||
};
|
||||
</script>
|
Before Width: | Height: | Size: 41 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48"><title>Accessibility</title><circle cx="24.334" cy="24" r="24" fill="#A849FF" fill-opacity=".3"/><path fill="#A470D5" fill-rule="evenodd" d="M27.8609 11.585C27.8609 9.59506 26.2497 7.99023 24.2519 7.99023C22.254 7.99023 20.6429 9.65925 20.6429 11.585C20.6429 13.575 22.254 15.1799 24.2519 15.1799C26.2497 15.1799 27.8609 13.575 27.8609 11.585ZM21.8922 22.6473C21.8467 23.9096 21.7901 25.4788 21.5897 26.2771C20.9853 29.0462 17.7348 36.3314 17.3325 37.2275C17.1891 37.4923 17.1077 37.7955 17.1077 38.1178C17.1077 39.1519 17.946 39.9902 18.9802 39.9902C19.6587 39.9902 20.253 39.6293 20.5814 39.0889L20.6429 38.9874L24.2841 31.22C24.2841 31.22 27.5529 37.9214 27.9238 38.6591C28.2948 39.3967 28.8709 39.9902 29.7168 39.9902C30.751 39.9902 31.5893 39.1519 31.5893 38.1178C31.5893 37.7951 31.3639 37.2265 31.3639 37.2265C30.9581 36.3258 27.698 29.0452 27.0938 26.2771C26.8975 25.4948 26.847 23.9722 26.8056 22.7236C26.7927 22.333 26.7806 21.9693 26.7653 21.6634C26.7008 21.214 27.0231 20.8289 27.4097 20.7005L35.3366 18.3253C36.3033 18.0685 36.8834 16.9773 36.6256 16.0144C36.3678 15.0515 35.2722 14.4737 34.3055 14.7305C34.3055 14.7305 26.8619 17.1057 24.2841 17.1057C21.7062 17.1057 14.456 14.7947 14.456 14.7947C13.4893 14.5379 12.3937 14.9873 12.0715 15.9502C11.7493 16.9131 12.3293 18.0044 13.3604 18.3253L21.2873 20.7005C21.674 20.8289 21.9318 21.214 21.9318 21.6634C21.9174 21.9493 21.9053 22.2857 21.8922 22.6473Z" clip-rule="evenodd"/></svg>
|
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 456 KiB |
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 6.0 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="33" height="32" fill="none" viewBox="0 0 33 32"><g clip-path="url(#clip0_10031_177575)"><mask id="mask0_10031_177575" style="mask-type:luminance" width="33" height="25" x="0" y="4" maskUnits="userSpaceOnUse"><path fill="#fff" d="M32.5034 4.00195H0.503906V28.7758H32.5034V4.00195Z"/></mask><g mask="url(#mask0_10031_177575)"><path fill="#5865F2" d="M27.5928 6.20817C25.5533 5.27289 23.3662 4.58382 21.0794 4.18916C21.0378 4.18154 20.9962 4.20057 20.9747 4.23864C20.6935 4.73863 20.3819 5.3909 20.1637 5.90358C17.7042 5.53558 15.2573 5.53558 12.8481 5.90358C12.6299 5.37951 12.307 4.73863 12.0245 4.23864C12.003 4.20184 11.9614 4.18281 11.9198 4.18916C9.63431 4.58255 7.44721 5.27163 5.40641 6.20817C5.38874 6.21578 5.3736 6.22848 5.36355 6.24497C1.21508 12.439 0.078646 18.4809 0.636144 24.4478C0.638667 24.477 0.655064 24.5049 0.677768 24.5227C3.41481 26.5315 6.06609 27.7511 8.66815 28.5594C8.70979 28.5721 8.75392 28.5569 8.78042 28.5226C9.39594 27.6826 9.94461 26.7968 10.4151 25.8653C10.4428 25.8107 10.4163 25.746 10.3596 25.7244C9.48927 25.3945 8.66058 24.9922 7.86343 24.5354C7.80038 24.4986 7.79533 24.4084 7.85333 24.3653C8.02108 24.2397 8.18888 24.109 8.34906 23.977C8.37804 23.9529 8.41842 23.9478 8.45249 23.963C13.6894 26.3526 19.359 26.3526 24.5341 23.963C24.5682 23.9465 24.6086 23.9516 24.6388 23.9757C24.799 24.1077 24.9668 24.2397 25.1358 24.3653C25.1938 24.4084 25.19 24.4986 25.127 24.5354C24.3298 25.0011 23.5011 25.3945 22.6296 25.7232C22.5728 25.7447 22.5476 25.8107 22.5754 25.8653C23.0559 26.7955 23.6046 27.6812 24.2087 28.5213C24.234 28.5569 24.2794 28.5721 24.321 28.5594C26.9357 27.7511 29.5869 26.5315 32.324 24.5227C32.348 24.5049 32.3631 24.4783 32.3656 24.4491C33.0328 17.5506 31.2481 11.5584 27.6344 6.24623C27.6256 6.22848 27.6105 6.21578 27.5928 6.20817ZM11.1971 20.8146C9.62043 20.8146 8.32129 19.3679 8.32129 17.5913C8.32129 15.8146 9.59523 14.368 11.1971 14.368C12.8115 14.368 14.0981 15.8273 14.0729 17.5913C14.0729 19.3679 12.7989 20.8146 11.1971 20.8146ZM21.8299 20.8146C20.2533 20.8146 18.9541 19.3679 18.9541 17.5913C18.9541 15.8146 20.228 14.368 21.8299 14.368C23.4444 14.368 24.7309 15.8273 24.7057 17.5913C24.7057 19.3679 23.4444 20.8146 21.8299 20.8146Z"/></g></g><defs><clipPath id="clip0_10031_177575"><rect width="32" height="32" fill="#fff" transform="translate(0.5)"/></clipPath></defs></svg>
|
Before Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 43 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path fill="#161614" d="M16.0001 0C7.16466 0 0 7.17472 0 16.0256C0 23.1061 4.58452 29.1131 10.9419 31.2322C11.7415 31.3805 12.0351 30.8845 12.0351 30.4613C12.0351 30.0791 12.0202 28.8167 12.0133 27.4776C7.56209 28.447 6.62283 25.5868 6.62283 25.5868C5.89499 23.7345 4.8463 23.2419 4.8463 23.2419C3.39461 22.2473 4.95573 22.2678 4.95573 22.2678C6.56242 22.3808 7.40842 23.9192 7.40842 23.9192C8.83547 26.3691 11.1514 25.6609 12.0645 25.2514C12.2081 24.2156 12.6227 23.5087 13.0803 23.1085C9.52648 22.7032 5.7906 21.3291 5.7906 15.1886C5.7906 13.4389 6.41563 12.0094 7.43916 10.8871C7.27303 10.4834 6.72537 8.85349 7.59415 6.64609C7.59415 6.64609 8.93774 6.21539 11.9953 8.28877C13.2716 7.9337 14.6404 7.75563 16.0001 7.74953C17.3599 7.75563 18.7297 7.9337 20.0084 8.28877C23.0623 6.21539 24.404 6.64609 24.404 6.64609C25.2749 8.85349 24.727 10.4834 24.5608 10.8871C25.5868 12.0094 26.2075 13.4389 26.2075 15.1886C26.2075 21.3437 22.4645 22.699 18.9017 23.0957C19.4756 23.593 19.9869 24.5683 19.9869 26.0634C19.9869 28.2077 19.9684 29.9334 19.9684 30.4613C19.9684 30.8877 20.2564 31.3874 21.0674 31.2301C27.4213 29.1086 32 23.1037 32 16.0256C32 7.17472 24.8364 0 16.0001 0ZM5.99257 22.8288C5.95733 22.9084 5.83227 22.9322 5.71834 22.8776C5.60229 22.8253 5.53711 22.7168 5.57474 22.6369C5.60918 22.5549 5.7345 22.5321 5.85029 22.587C5.9666 22.6393 6.03284 22.7489 5.99257 22.8288ZM6.7796 23.5321C6.70329 23.603 6.55412 23.5701 6.45291 23.4581C6.34825 23.3464 6.32864 23.197 6.40601 23.125C6.4847 23.0542 6.62937 23.0874 6.73429 23.1991C6.83895 23.3121 6.85935 23.4605 6.7796 23.5321ZM7.31953 24.4321C7.2215 24.5003 7.0612 24.4363 6.96211 24.2938C6.86407 24.1513 6.86407 23.9804 6.96422 23.9119C7.06358 23.8435 7.2215 23.905 7.32191 24.0465C7.41968 24.1914 7.41968 24.3623 7.31953 24.4321ZM8.23267 25.4743C8.14497 25.5712 7.95818 25.5452 7.82146 25.413C7.68156 25.2838 7.64261 25.1004 7.73058 25.0035C7.81934 24.9064 8.00719 24.9337 8.14497 25.0648C8.28381 25.1938 8.3262 25.3785 8.23267 25.4743ZM9.41281 25.8262C9.37413 25.9517 9.19423 26.0088 9.013 25.9554C8.83203 25.9005 8.7136 25.7535 8.75016 25.6266C8.78778 25.5003 8.96848 25.4408 9.15104 25.4979C9.33174 25.5526 9.45044 25.6985 9.41281 25.8262ZM10.7559 25.9754C10.7604 26.1076 10.6067 26.2172 10.4165 26.2196C10.2252 26.2238 10.0704 26.1169 10.0683 25.9868C10.0683 25.8534 10.2185 25.7448 10.4098 25.7416C10.6001 25.7379 10.7559 25.8441 10.7559 25.9754ZM12.0753 25.9248C12.0981 26.0537 11.9658 26.1862 11.7769 26.2215C11.5912 26.2554 11.4192 26.1758 11.3957 26.0479C11.3726 25.9157 11.5072 25.7833 11.6927 25.7491C11.8819 25.7162 12.0512 25.7937 12.0753 25.9248Z"/></svg>
|
Before Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 43 KiB |