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 @@ 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 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 = { + 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; + +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 @@ + + + + + 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: 'Footer' }, + }); + 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': '
Custom Name Header
' }, + }); + 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': '' }, + }); + 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': '' + }, + }); + 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 = '
No items available.
'; + 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': 'Empty' }, + }); + 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 = 'Slot Caption'; + 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 = { + 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; + +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: '
' })], +}; + +export const CustomCellRendering: Story = { + render: (args) => ({ + components: { VTable, VBadge, VAvatar }, + setup() { return { args }; }, + template: ` + + + + + + `, + }), + 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: ` + + + + + `, + }), + 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: ` + + + + `, + }), + args: { + headers: sampleHeaders, + items: [], // Empty items array + }, +}; + +export const WithFooter: Story = { + render: (args) => ({ + components: { VTable }, + setup() { return { args }; }, + template: ` + + + + `, + }), + 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: '
' })], +}; + + +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: ` + + + + `, + }), + 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 `` generation, but `` 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 @@ + + + + + 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 = ''; + 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: 'Trigger' }, + }); + 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: 'Non-focusable by default' }, + }); + 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 = { + 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: '
' })], +}; + +export default meta; +type Story = StoryObj; + +export const Top: Story = { + render: (args) => ({ + components: { VTooltip, VButton }, + setup() { return { args }; }, + template: ` + + Hover or Focus Me (Top) + + `, + }), + 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: ` +

+ Some text here, and + + this part has a tooltip + + which shows up on hover or focus. +

+ `, + }), + 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 @@ + + + + + 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 @@
- + + + -
- -

No Groups Yet!

-

You are not a member of any groups yet. Create one or join using an invite code.

- -
+ + +

{{ group.name }}

- +
@@ -56,52 +48,48 @@
-
- - -
- +
-

{{ joinGroupFormError }}

+
- + +
+ + + + +
+
@@ -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(null); const showCreateGroupDialog = ref(false); const newGroupName = ref(''); const creatingGroup = ref(false); -const newGroupNameInputRef = ref(null); -const createGroupModalRef = ref(null); +const newGroupNameInputRef = ref | null>(null); // Changed type to VInput instance +// const createGroupModalRef = ref(null); // No longer needed const createGroupFormError = ref(null); const inviteCodeToJoin = ref(''); const joiningGroup = ref(false); -const joinInviteCodeInputRef = ref(null); +const joinInviteCodeInputRef = ref | null>(null); // Changed type to VInput instance const joinGroupFormError = ref(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 @@
- + + + -
- -

{{ noListsMessage }}

-

Create a personal list or join a group to see shared lists.

-

This group doesn't have any lists yet.

- -
+ + + +
@@ -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;