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.
This commit is contained in:
parent
fc16f169b1
commit
272e5abe41
@ -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;
|
||||
|
65
fe/src/components/valerie/VHeading.spec.ts
Normal file
65
fe/src/components/valerie/VHeading.spec.ts
Normal file
@ -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('');
|
||||
});
|
||||
});
|
100
fe/src/components/valerie/VHeading.stories.ts
Normal file
100
fe/src/components/valerie/VHeading.stories.ts
Normal file
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
35
fe/src/components/valerie/VHeading.vue
Normal file
35
fe/src/components/valerie/VHeading.vue
Normal file
@ -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>
|
55
fe/src/components/valerie/VSpinner.spec.ts
Normal file
55
fe/src/components/valerie/VSpinner.spec.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
64
fe/src/components/valerie/VSpinner.stories.ts
Normal file
64
fe/src/components/valerie/VSpinner.stories.ts
Normal file
@ -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.' },
|
||||
},
|
||||
},
|
||||
};
|
95
fe/src/components/valerie/VSpinner.vue
Normal file
95
fe/src/components/valerie/VSpinner.vue
Normal file
@ -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>
|
162
fe/src/components/valerie/VTable.spec.ts
Normal file
162
fe/src/components/valerie/VTable.spec.ts
Normal file
@ -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
|
||||
});
|
||||
});
|
229
fe/src/components/valerie/VTable.stories.ts
Normal file
229
fe/src/components/valerie/VTable.stories.ts
Normal file
@ -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."}
|
||||
}
|
||||
}
|
||||
};
|
170
fe/src/components/valerie/VTable.vue
Normal file
170
fe/src/components/valerie/VTable.vue
Normal file
@ -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>
|
103
fe/src/components/valerie/VTooltip.spec.ts
Normal file
103
fe/src/components/valerie/VTooltip.spec.ts
Normal file
@ -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.
|
||||
});
|
120
fe/src/components/valerie/VTooltip.stories.ts
Normal file
120
fe/src/components/valerie/VTooltip.stories.ts
Normal file
@ -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',
|
||||
},
|
||||
};
|
151
fe/src/components/valerie/VTooltip.vue
Normal file
151
fe/src/components/valerie/VTooltip.vue
Normal file
@ -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>
|
@ -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>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<VModal v-model="showCreateGroupDialog" title="Create New Group" @update:modelValue="val => !val && closeCreateGroupDialog()">
|
||||
<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>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</VButton>
|
||||
</template>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</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;
|
||||
|
@ -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>
|
||||
<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>
|
||||
<button class="btn btn-primary mt-2" @click="showCreateModal = true">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg>
|
||||
</template>
|
||||
<template #empty-actions>
|
||||
<VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus">
|
||||
Create New List
|
||||
</button>
|
||||
</div>
|
||||
</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;
|
||||
|
Loading…
Reference in New Issue
Block a user