diff --git a/fe/src/components/CreateListModal.vue b/fe/src/components/CreateListModal.vue index 835beb4..c33d4dd 100644 --- a/fe/src/components/CreateListModal.vue +++ b/fe/src/components/CreateListModal.vue @@ -1,54 +1,43 @@ <template> - <div v-if="isOpen" class="modal-backdrop open" @click.self="closeModal"> - <div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="createListModalTitle"> - <div class="modal-header"> - <h3 id="createListModalTitle">Create New List</h3> - <button class="close-button" @click="closeModal" aria-label="Close modal"> - <svg class="icon" aria-hidden="true"> - <use xlink:href="#icon-close" /> - </svg> - </button> - </div> + <VModal :model-value="isOpen" @update:model-value="closeModal" title="Create New List"> + <template #default> <form @submit.prevent="onSubmit"> - <div class="modal-body"> - <div class="form-group"> - <label for="listName" class="form-label">List Name</label> - <input type="text" id="listName" v-model="listName" class="form-input" required ref="listNameInput" /> - <p v-if="formErrors.listName" class="form-error-text">{{ formErrors.listName }}</p> - </div> + <VFormField label="List Name" :error-message="formErrors.listName"> + <VInput type="text" v-model="listName" required ref="listNameInput" /> + </VFormField> - <div class="form-group"> - <label for="description" class="form-label">Description</label> - <textarea id="description" v-model="description" class="form-input" rows="3"></textarea> - </div> + <VFormField label="Description"> + <VTextarea v-model="description" rows="3" /> + </VFormField> - <div class="form-group" v-if="groups && groups.length > 0"> - <label for="selectedGroup" class="form-label">Associate with Group (Optional)</label> - <select id="selectedGroup" v-model="selectedGroupId" class="form-input"> - <option :value="null">None</option> - <option v-for="group in groups" :key="group.value" :value="group.value"> - {{ group.label }} - </option> - </select> - </div> - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-neutral" @click="closeModal">Cancel</button> - <button type="submit" class="btn btn-primary ml-2" :disabled="loading"> - <span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span> - Create - </button> - </div> + <VFormField label="Associate with Group (Optional)" v-if="props.groups && props.groups.length > 0"> + <VSelect v-model="selectedGroupId" :options="groupOptionsForSelect" placeholder="None" /> + </VFormField> + <!-- Form submission is handled by button in footer slot --> </form> - </div> - </div> + </template> + <template #footer> + <VButton variant="neutral" @click="closeModal" type="button">Cancel</VButton> + <VButton type="submit" variant="primary" :disabled="loading" @click="onSubmit" class="ml-2"> + <VSpinner v-if="loading" size="sm" /> + Create + </VButton> + </template> + </VModal> </template> <script setup lang="ts"> -import { ref, watch, nextTick } from 'vue'; -import { useVModel, onClickOutside } from '@vueuse/core'; +import { ref, watch, nextTick, computed } from 'vue'; +import { useVModel } from '@vueuse/core'; // onClickOutside removed import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming this path is correct import { useNotificationStore } from '@/stores/notifications'; +import VModal from '@/components/valerie/VModal.vue'; +import VFormField from '@/components/valerie/VFormField.vue'; +import VInput from '@/components/valerie/VInput.vue'; +import VTextarea from '@/components/valerie/VTextarea.vue'; +import VSelect from '@/components/valerie/VSelect.vue'; +import VButton from '@/components/valerie/VButton.vue'; +import VSpinner from '@/components/valerie/VSpinner.vue'; const props = defineProps<{ modelValue: boolean; @@ -68,27 +57,35 @@ const loading = ref(false); const formErrors = ref<{ listName?: string }>({}); const notificationStore = useNotificationStore(); -const listNameInput = ref<HTMLInputElement | null>(null); -const modalContainerRef = ref<HTMLElement | null>(null); // For onClickOutside +const listNameInput = ref<InstanceType<typeof VInput> | null>(null); +// const modalContainerRef = ref<HTMLElement | null>(null); // Removed + +const groupOptionsForSelect = computed(() => { + const options = props.groups ? props.groups.map(g => ({ label: g.label, value: g.value })) : []; + // VSelect expects placeholder to be passed as a prop, not as an option for empty value usually + // However, if 'None' is a valid selectable option representing null, this is okay. + // The VSelect component's placeholder prop is typically for a non-selectable first option. + // Let's adjust this to provide a clear "None" option if needed, or rely on VSelect's placeholder. + // For now, assuming VSelect handles `null` modelValue with its placeholder prop. + // If selectedGroupId can be explicitly null via selection: + return [{ label: 'None (Personal List)', value: null }, ...options]; +}); + watch(isOpen, (newVal) => { if (newVal) { // Reset form when opening listName.value = ''; description.value = ''; - selectedGroupId.value = null; + selectedGroupId.value = null; // Default to 'None' or personal list formErrors.value = {}; nextTick(() => { - listNameInput.value?.focus(); + listNameInput.value?.focus?.(); }); } }); -onClickOutside(modalContainerRef, () => { - if (isOpen.value) { - closeModal(); - } -}); +// onClickOutside removed, VModal handles backdrop clicks const closeModal = () => { isOpen.value = false; diff --git a/fe/src/components/valerie/VHeading.spec.ts b/fe/src/components/valerie/VHeading.spec.ts new file mode 100644 index 0000000..844a6ef --- /dev/null +++ b/fe/src/components/valerie/VHeading.spec.ts @@ -0,0 +1,65 @@ +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(''); + }); +}); diff --git a/fe/src/components/valerie/VHeading.stories.ts b/fe/src/components/valerie/VHeading.stories.ts new file mode 100644 index 0000000..ea7853a --- /dev/null +++ b/fe/src/components/valerie/VHeading.stories.ts @@ -0,0 +1,100 @@ +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.', + }, + }, + }, +}; diff --git a/fe/src/components/valerie/VHeading.vue b/fe/src/components/valerie/VHeading.vue new file mode 100644 index 0000000..a9be003 --- /dev/null +++ b/fe/src/components/valerie/VHeading.vue @@ -0,0 +1,35 @@ +<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> diff --git a/fe/src/components/valerie/VSpinner.spec.ts b/fe/src/components/valerie/VSpinner.spec.ts new file mode 100644 index 0000000..8c1225b --- /dev/null +++ b/fe/src/components/valerie/VSpinner.spec.ts @@ -0,0 +1,55 @@ +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); + }); +}); diff --git a/fe/src/components/valerie/VSpinner.stories.ts b/fe/src/components/valerie/VSpinner.stories.ts new file mode 100644 index 0000000..2203666 --- /dev/null +++ b/fe/src/components/valerie/VSpinner.stories.ts @@ -0,0 +1,64 @@ +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.' }, + }, + }, +}; diff --git a/fe/src/components/valerie/VSpinner.vue b/fe/src/components/valerie/VSpinner.vue new file mode 100644 index 0000000..99f11c2 --- /dev/null +++ b/fe/src/components/valerie/VSpinner.vue @@ -0,0 +1,95 @@ +<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> diff --git a/fe/src/components/valerie/VTable.spec.ts b/fe/src/components/valerie/VTable.spec.ts new file mode 100644 index 0000000..3fb07f3 --- /dev/null +++ b/fe/src/components/valerie/VTable.spec.ts @@ -0,0 +1,162 @@ +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 + }); +}); diff --git a/fe/src/components/valerie/VTable.stories.ts b/fe/src/components/valerie/VTable.stories.ts new file mode 100644 index 0000000..404d8e2 --- /dev/null +++ b/fe/src/components/valerie/VTable.stories.ts @@ -0,0 +1,229 @@ +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."} + } + } +}; diff --git a/fe/src/components/valerie/VTable.vue b/fe/src/components/valerie/VTable.vue new file mode 100644 index 0000000..3635166 --- /dev/null +++ b/fe/src/components/valerie/VTable.vue @@ -0,0 +1,170 @@ +<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> diff --git a/fe/src/components/valerie/VTooltip.spec.ts b/fe/src/components/valerie/VTooltip.spec.ts new file mode 100644 index 0000000..467f9ed --- /dev/null +++ b/fe/src/components/valerie/VTooltip.spec.ts @@ -0,0 +1,103 @@ +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. +}); diff --git a/fe/src/components/valerie/VTooltip.stories.ts b/fe/src/components/valerie/VTooltip.stories.ts new file mode 100644 index 0000000..aa63c47 --- /dev/null +++ b/fe/src/components/valerie/VTooltip.stories.ts @@ -0,0 +1,120 @@ +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', + }, +}; diff --git a/fe/src/components/valerie/VTooltip.vue b/fe/src/components/valerie/VTooltip.vue new file mode 100644 index 0000000..4079e82 --- /dev/null +++ b/fe/src/components/valerie/VTooltip.vue @@ -0,0 +1,151 @@ +<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> diff --git a/fe/src/pages/GroupsPage.vue b/fe/src/pages/GroupsPage.vue index b0fb71b..ea0b40b 100644 --- a/fe/src/pages/GroupsPage.vue +++ b/fe/src/pages/GroupsPage.vue @@ -2,41 +2,33 @@ <main class="container page-padding"> <!-- <h1 class="mb-3">Your Groups</h1> --> - <div v-if="fetchError" class="alert alert-error mb-3" role="alert"> - <div class="alert-content"> - <svg class="icon" aria-hidden="true"> - <use xlink:href="#icon-alert-triangle" /> - </svg> - {{ fetchError }} - </div> - <button type="button" class="btn btn-sm btn-danger" @click="fetchGroups">Retry</button> - </div> + <VAlert v-if="fetchError" type="error" :message="fetchError" class="mb-3" :closable="false"> + <template #actions> + <VButton variant="danger" size="sm" @click="fetchGroups">Retry</VButton> + </template> + </VAlert> - <div v-else-if="groups.length === 0" class="card empty-state-card"> - <svg class="icon icon-lg" aria-hidden="true"> - <use xlink:href="#icon-clipboard" /> - </svg> - <h3>No Groups Yet!</h3> - <p>You are not a member of any groups yet. Create one or join using an invite code.</p> - <button class="btn btn-primary mt-2" @click="openCreateGroupDialog"> - <svg class="icon" aria-hidden="true"> - <use xlink:href="#icon-plus" /> - </svg> - Create New Group - </button> - </div> + <VCard v-else-if="groups.length === 0" + variant="empty-state" + empty-icon="clipboard" + empty-title="No Groups Yet!" + empty-message="You are not a member of any groups yet. Create one or join using an invite code." + > + <template #empty-actions> + <VButton variant="primary" class="mt-2" @click="openCreateGroupDialog" icon-left="plus"> + Create New Group + </VButton> + </template> + </VCard> <div v-else class="mb-3"> <div class="neo-groups-grid"> <div v-for="group in groups" :key="group.id" class="neo-group-card" @click="selectGroup(group)"> <h1 class="neo-group-header">{{ group.name }}</h1> <div class="neo-group-actions"> - <button class="btn btn-sm btn-secondary" @click.stop="openCreateListDialog(group)"> - <svg class="icon" aria-hidden="true"> - <use xlink:href="#icon-plus" /> - </svg> + <VButton size="sm" variant="secondary" @click.stop="openCreateListDialog(group)" icon-left="plus"> List - </button> + </VButton> </div> </div> <div class="neo-create-group-card" @click="openCreateGroupDialog"> @@ -56,52 +48,48 @@ </summary> <div class="card-body"> <form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;"> - <div class="form-group flex-grow" style="margin-bottom: 0;"> - <label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label> - <input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input" - placeholder="Enter Invite Code" required ref="joinInviteCodeInputRef" /> - </div> - <button type="submit" class="btn btn-secondary" :disabled="joiningGroup"> - <span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span> + <VFormField class="flex-grow" :error-message="joinGroupFormError" label="Enter Invite Code" :label-sr-only="true"> + <VInput + type="text" + id="joinInviteCodeInput" + v-model="inviteCodeToJoin" + placeholder="Enter Invite Code" + required + ref="joinInviteCodeInputRef" + /> + </VFormField> + <VButton type="submit" variant="secondary" :disabled="joiningGroup"> + <VSpinner v-if="joiningGroup" size="sm" /> Join - </button> + </VButton> </form> - <p v-if="joinGroupFormError" class="form-error-text mt-1">{{ joinGroupFormError }}</p> + <!-- The error message is now handled by VFormField --> </div> </details> </div> <!-- Create Group Dialog --> - <div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog"> - <div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true" - aria-labelledby="createGroupTitle"> - <div class="modal-header"> - <h3 id="createGroupTitle">Create New Group</h3> - <button class="close-button" @click="closeCreateGroupDialog" aria-label="Close"> - <svg class="icon" aria-hidden="true"> - <use xlink:href="#icon-close" /> - </svg> - </button> - </div> - <form @submit.prevent="handleCreateGroup"> - <div class="modal-body"> - <div class="form-group"> - <label for="newGroupNameInput" class="form-label">Group Name</label> - <input type="text" id="newGroupNameInput" v-model="newGroupName" class="form-input" required - ref="newGroupNameInputRef" /> - <p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p> - </div> - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">Cancel</button> - <button type="submit" class="btn btn-primary ml-2" :disabled="creatingGroup"> - <span v-if="creatingGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span> - Create - </button> - </div> - </form> - </div> - </div> + <VModal v-model="showCreateGroupDialog" title="Create New Group" @update:modelValue="val => !val && closeCreateGroupDialog()"> + <form @submit.prevent="handleCreateGroup"> + <VFormField label="Group Name" :error-message="createGroupFormError"> + <VInput + type="text" + v-model="newGroupName" + placeholder="Enter group name" + required + id="newGroupNameInput" + ref="newGroupNameInputRef" + /> + </VFormField> + <template #footer> + <VButton variant="neutral" @click="closeCreateGroupDialog" type="button">Cancel</VButton> + <VButton type="submit" variant="primary" :disabled="creatingGroup"> + <VSpinner v-if="creatingGroup" size="sm" /> + Create + </VButton> + </template> + </form> + </VModal> <!-- Create List Modal --> <CreateListModal v-model="showCreateListModal" :groups="availableGroupsForModal" @created="onListCreated" /> @@ -113,9 +101,16 @@ import { ref, onMounted, nextTick } from 'vue'; import { useRouter } from 'vue-router'; import { apiClient, API_ENDPOINTS } from '@/config/api'; import { useStorage } from '@vueuse/core'; -import { onClickOutside } from '@vueuse/core'; +// import { onClickOutside } from '@vueuse/core'; // No longer needed for VModal import { useNotificationStore } from '@/stores/notifications'; import CreateListModal from '@/components/CreateListModal.vue'; +import VModal from '@/components/valerie/VModal.vue'; +import VFormField from '@/components/valerie/VFormField.vue'; +import VInput from '@/components/valerie/VInput.vue'; +import VButton from '@/components/valerie/VButton.vue'; +import VSpinner from '@/components/valerie/VSpinner.vue'; +import VAlert from '@/components/valerie/VAlert.vue'; +import VCard from '@/components/valerie/VCard.vue'; interface Group { id: number; @@ -135,13 +130,13 @@ const fetchError = ref<string | null>(null); const showCreateGroupDialog = ref(false); const newGroupName = ref(''); const creatingGroup = ref(false); -const newGroupNameInputRef = ref<HTMLInputElement | null>(null); -const createGroupModalRef = ref<HTMLElement | null>(null); +const newGroupNameInputRef = ref<InstanceType<typeof VInput> | null>(null); // Changed type to VInput instance +// const createGroupModalRef = ref<HTMLElement | null>(null); // No longer needed const createGroupFormError = ref<string | null>(null); const inviteCodeToJoin = ref(''); const joiningGroup = ref(false); -const joinInviteCodeInputRef = ref<HTMLInputElement | null>(null); +const joinInviteCodeInputRef = ref<InstanceType<typeof VInput> | null>(null); // Changed type to VInput instance const joinGroupFormError = ref<string | null>(null); const showCreateListModal = ref(false); @@ -183,7 +178,12 @@ const openCreateGroupDialog = () => { createGroupFormError.value = null; showCreateGroupDialog.value = true; nextTick(() => { - newGroupNameInputRef.value?.focus(); + // Attempt to focus VInput. This assumes VInput exposes a focus method + // or internally focuses its input element on a `focus()` call. + // If VInput's input element needs to be accessed directly, it might be: + // newGroupNameInputRef.value?.$el.querySelector('input')?.focus(); or similar, + // but ideally VInput itself handles this. + newGroupNameInputRef.value?.focus?.(); }); }; @@ -191,12 +191,12 @@ const closeCreateGroupDialog = () => { showCreateGroupDialog.value = false; }; -onClickOutside(createGroupModalRef, closeCreateGroupDialog); +// onClickOutside(createGroupModalRef, closeCreateGroupDialog); // Replaced by VModal's own handling const handleCreateGroup = async () => { if (!newGroupName.value.trim()) { createGroupFormError.value = 'Group name is required'; - newGroupNameInputRef.value?.focus(); + newGroupNameInputRef.value?.focus?.(); // Use VInput's focus method if available return; } createGroupFormError.value = null; @@ -229,7 +229,7 @@ const handleCreateGroup = async () => { const handleJoinGroup = async () => { if (!inviteCodeToJoin.value.trim()) { joinGroupFormError.value = 'Invite code is required'; - joinInviteCodeInputRef.value?.focus(); + joinInviteCodeInputRef.value?.focus?.(); // Use VInput's focus method if available return; } joinGroupFormError.value = null; diff --git a/fe/src/pages/ListsPage.vue b/fe/src/pages/ListsPage.vue index 31975f2..e325e10 100644 --- a/fe/src/pages/ListsPage.vue +++ b/fe/src/pages/ListsPage.vue @@ -2,30 +2,27 @@ <main class="container page-padding"> <!-- <h1 class="mb-3">{{ pageTitle }}</h1> --> - <div v-if="error" class="alert alert-error mb-3" role="alert"> - <div class="alert-content"> - <svg class="icon" aria-hidden="true"> - <use xlink:href="#icon-alert-triangle" /> - </svg> - {{ error }} - </div> - <button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button> - </div> + <VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false"> + <template #actions> + <VButton variant="danger" size="sm" @click="fetchListsAndGroups">Retry</VButton> + </template> + </VAlert> - <div v-else-if="lists.length === 0" class="card empty-state-card"> - <svg class="icon icon-lg" aria-hidden="true"> - <use xlink:href="#icon-clipboard" /> - </svg> - <h3>{{ noListsMessage }}</h3> - <p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p> - <p v-else>This group doesn't have any lists yet.</p> - <button class="btn btn-primary mt-2" @click="showCreateModal = true"> - <svg class="icon" aria-hidden="true"> - <use xlink:href="#icon-plus" /> - </svg> - Create New List - </button> - </div> + <VCard v-else-if="lists.length === 0" + variant="empty-state" + empty-icon="clipboard" + :empty-title="noListsMessage" + > + <template #default> + <p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p> + <p v-else>This group doesn't have any lists yet.</p> + </template> + <template #empty-actions> + <VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus"> + Create New List + </VButton> + </template> + </VCard> <div v-else> <div class="neo-lists-grid"> @@ -67,6 +64,10 @@ import { useRoute, useRouter } from 'vue-router'; import { apiClient, API_ENDPOINTS } from '@/config/api'; import CreateListModal from '@/components/CreateListModal.vue'; import { useStorage } from '@vueuse/core'; +import VAlert from '@/components/valerie/VAlert.vue'; +import VCard from '@/components/valerie/VCard.vue'; +import VButton from '@/components/valerie/VButton.vue'; +// VSpinner might not be needed here unless other parts use it directly interface List { id: number;