From 272e5abe41a9f6052376bc0690b7227e5e9e4ce9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Jun 2025 08:40:21 +0000 Subject: [PATCH] refactor: Integrate Valerie UI components into Group and List pages This commit refactors parts of `GroupsPage.vue`, `ListsPage.vue`, and the shared `CreateListModal.vue` to use the newly created Valerie UI components. Key changes include: 1. **Modals:** * The "Create Group Dialog" in `GroupsPage.vue` now uses `VModal`, `VFormField`, `VInput`, `VButton`, and `VSpinner`. * The `CreateListModal.vue` component (used by both pages) has been internally refactored to use `VModal`, `VFormField`, `VInput`, `VTextarea`, `VSelect`, `VButton`, and `VSpinner`. 2. **Forms:** * The "Join Group" form in `GroupsPage.vue` now uses `VFormField`, `VInput`, `VButton`, and `VSpinner`. 3. **Alerts:** * Error alerts in both `GroupsPage.vue` and `ListsPage.vue` now use the `VAlert` component, with retry buttons placed in the `actions` slot. 4. **Empty States:** * The empty state displays (e.g., "No Groups Yet", "No lists found") in both pages now use the `VCard` component with `variant="empty-state"`, mapping content to the relevant props and slots. 5. **Buttons:** * Various standalone buttons (e.g., "Create New Group", "Create New List", "List" button on group cards) have been updated to use the `VButton` component with appropriate props for variants, sizes, and icons. **Scope of this Refactor:** * The focus was on replacing direct usages of custom-styled modal dialogs, form elements, alerts, and buttons with their Valerie UI component counterparts. * Highly custom card-like structures such as `neo-group-card` (in `GroupsPage.vue`) and `neo-list-card` (in `ListsPage.vue`), along with their specific "create" card variants, have been kept with their existing custom styling for this phase. This is due to their unique layouts and styling not directly mapping to the current generic `VCard` component without significant effort or potential introduction of overly specific props to `VCard`. Only buttons within these custom cards were refactored. * The internal item rendering within `neo-list-card` (custom checkboxes, add item input) also remains custom for now. This refactoring improves consistency by leveraging the standardized Valerie UI components for common UI patterns like modals, forms, alerts, and buttons on these pages. --- fe/src/components/CreateListModal.vue | 97 ++++---- fe/src/components/valerie/VHeading.spec.ts | 65 +++++ fe/src/components/valerie/VHeading.stories.ts | 100 ++++++++ fe/src/components/valerie/VHeading.vue | 35 +++ fe/src/components/valerie/VSpinner.spec.ts | 55 +++++ fe/src/components/valerie/VSpinner.stories.ts | 64 +++++ fe/src/components/valerie/VSpinner.vue | 95 ++++++++ fe/src/components/valerie/VTable.spec.ts | 162 +++++++++++++ fe/src/components/valerie/VTable.stories.ts | 229 ++++++++++++++++++ fe/src/components/valerie/VTable.vue | 170 +++++++++++++ fe/src/components/valerie/VTooltip.spec.ts | 103 ++++++++ fe/src/components/valerie/VTooltip.stories.ts | 120 +++++++++ fe/src/components/valerie/VTooltip.vue | 151 ++++++++++++ fe/src/pages/GroupsPage.vue | 148 +++++------ fe/src/pages/ListsPage.vue | 47 ++-- 15 files changed, 1494 insertions(+), 147 deletions(-) create mode 100644 fe/src/components/valerie/VHeading.spec.ts create mode 100644 fe/src/components/valerie/VHeading.stories.ts create mode 100644 fe/src/components/valerie/VHeading.vue create mode 100644 fe/src/components/valerie/VSpinner.spec.ts create mode 100644 fe/src/components/valerie/VSpinner.stories.ts create mode 100644 fe/src/components/valerie/VSpinner.vue create mode 100644 fe/src/components/valerie/VTable.spec.ts create mode 100644 fe/src/components/valerie/VTable.stories.ts create mode 100644 fe/src/components/valerie/VTable.vue create mode 100644 fe/src/components/valerie/VTooltip.spec.ts create mode 100644 fe/src/components/valerie/VTooltip.stories.ts create mode 100644 fe/src/components/valerie/VTooltip.vue 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;