Compare commits

...

10 Commits

Author SHA1 Message Date
mohamad
1c87170955 refactor: use html for now
Some checks failed
Deploy to Production, build images and push to Gitea Registry / deploy (push) Failing after 2m14s
2025-06-01 14:27:46 +02:00
mohamad
74c73a9e8f refactor: Update GroupsPage to use standard HTML for now 2025-06-01 14:27:02 +02:00
mohamad
679169e4fb refactor: Simplify ChoresPage structure and enhance form functionality
- Removed redundant form elements and improved the layout for better readability.
- Streamlined the chore creation and editing process with enhanced validation and auto-save features.
- Updated keyboard shortcuts for improved accessibility and user experience.
- Enhanced modal interactions and improved loading states during data fetching.
- Cleaned up unused code and optimized the overall component structure.
2025-06-01 14:27:02 +02:00
google-labs-jules[bot]
a7fbc454a9 Refactor: Reset Alembic migrations and consolidate models.
This commit addresses issues with backend models, schemas, and migrations.

Key changes:
- Consolidated all SQLAlchemy model definitions into `be/app/models.py`.
- Emptied `be/app/models/expense.py` as its contents were duplicates.
- Verified and standardized Base class usage and SQLAlchemy imports in models.
- Confirmed the correctness of self-referential relationships in the `Expense` model.
- Added a clarifying comment to `SplitTypeEnum` regarding future extensibility.
- Corrected a typo in `Settlement.created_by_user_id`.

Migration Cleanup:
- Deleted all existing Alembic migration files from `be/alembic/versions/`.
- Created a new, single initial migration script (`0001_initial_schema.py`) that defines the entire database schema based on the current state of the SQLAlchemy models. This provides a clean slate for future migrations.

This reset was performed because the previous migration history was complex and contained a revision that was incompatible with the current model definitions. Starting fresh ensures consistency between the models and the database schema from the initial point.
2025-06-01 14:26:37 +02:00
google-labs-jules[bot]
813ed911f1 Okay, I've made some changes to integrate the Valerie UI components into the Account, Group Detail, and List Detail pages. This is part of the ongoing effort to standardize the UI and make the code easier to maintain.
Here's a breakdown of the changes:

1.  **`AccountPage.vue`**:
    *   I replaced the main heading with `VHeading`.
    *   I updated the loading spinner to `VSpinner`.
    *   I converted the error alert to `VAlert` with an action button.
    *   I refactored the Profile, Password, and Notifications sections to use `VCard` for their structure.
    *   The form elements within these cards (name, email, passwords) now use `VFormField` and `VInput`.
    *   Action buttons like "Save Changes" and "Change Password" are now `VButton` with an integrated `VSpinner` for loading states.
    *   The notification preferences list uses `VList` and `VListItem`, with each preference toggle converted to `VToggleSwitch`.

2.  **`GroupDetailPage.vue`**:
    *   I updated the page-level loading spinner, error alert, and main heading to `VSpinner`, `VAlert`, and `VHeading`.
    *   I refactored the "Group Members", "Invite Members", "Chores", and "Expenses" sections from custom "neo-card" styling to use `VCard`.
    *   Headers within these cards use `VHeading` and action buttons use `VButton` (I kept Material Icons where `VIcon` wasn't a direct replacement).
    *   Lists of members, chores, and expenses now use `VList` and `VListItem`.
    *   Buttons within list items (e.g., "Remove member") are `VButton` with `VSpinner`.
    *   Role indicators and frequency/split type "chips" are now `VBadge` components, and I updated the helper functions to return VBadge-compatible variants.
    *   The "Invite Members" form elements (input for code, copy button) use `VFormField`, `VInput`, and `VButton`.
    *   I simplified empty states within card bodies using `VIcon` and text.

3.  **`ListDetailPage.vue`**: This complex page required several steps to refactor:
    *   **Page-Level & Header:** I updated the loading state to `VSpinner`, the error alert to `VAlert`, and the main title to `VHeading`. Header action buttons are `VButton` with icons, and the list status is `VBadge`.
    *   **Modals:** I converted all five custom modals (OCR, Confirmation, Edit Item, Settle Share, Cost Summary shell) to use `VModal`. Internal forms and actions within these modals now use `VFormField`, `VInput`, `VButton`, `VSpinner`, `VList`, `VListItem`, and `VAlert` as appropriate. I removed the `onClickOutside` logic.
    *   **Main Items List:** The loading state uses `VCard` with `VSpinner`, and the empty state uses `VCard variant="empty-state"`. The list itself is now a `VCard` containing a `VList`. Each item is a `VListItem` with internal content refactored to use `VCheckbox`, `VInput` (for price), and `VButton` with `VIcon` for actions.
    *   **Add Item Form:** I re-structured this below the items list, using `VFormField`, `VInput`, and `VButton` with `VIcon`.
    *   **Expenses Section:** The main card uses `VCard` with `VHeading` and `VButton` in the header. Loading/error/empty states use `VSpinner`, `VAlert`, `VIcon`. The expenses list is `VList`, with each expense item as a `VListItem`. Statuses are `VBadge`.

This refactoring significantly increases the usage of the Valerie UI component library across these key application pages. This should help create a more consistent experience for you and make development smoother. Next, I'll focus on the Chores-related pages.
2025-06-01 14:26:37 +02:00
google-labs-jules[bot]
272e5abe41 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.
2025-06-01 14:26:37 +02:00
google-labs-jules[bot]
fc16f169b1 Jules was unable to complete the task in time. Please review the work done so far and provide feedback for Jules to continue. 2025-06-01 14:26:37 +02:00
google-labs-jules[bot]
3811dc7ee5 Refactor: Polish backend based on review
I reviewed the backend codebase covering schema, API endpoints, error handling, and tests.

Key changes I implemented:
- Updated `app/models.py`:
    - Added `parent_expense_id` and `last_occurrence` fields to the `Expense` model to align with the `add_recurring_expenses.py` migration.
    - Added `parent_expense` and `child_expenses` self-referential relationships to the `Expense` model.
- Updated `app/core/exceptions.py`:
    - Removed the unused and improperly defined `BalanceCalculationError` class.

I identified areas for future work:
- Create a new Alembic migration if necessary to ensure `parent_expense_id` and `last_occurrence` columns are correctly reflected in the database, or verify the existing `add_recurring_expenses.py` migration's status.
- Significantly improve API test coverage, particularly for:
    - Chores module (personal and group)
    - Groups, Invites, Lists, Items, OCR endpoints
    - Full CRUD operations for Expenses and Settlements
    - Recurring expense functionalities.
2025-06-01 14:26:36 +02:00
mohamad
136c4df7ac feat: Integrate Storybook for component development 2025-06-01 14:26:36 +02:00
mohamad
821a26e681 feat: Add Recurrence Pattern and Update Expense Schema
- Introduced a new `RecurrencePattern` model to manage recurrence details for expenses, allowing for daily, weekly, monthly, and yearly patterns.
- Updated the `Expense` model to include fields for recurrence management, such as `is_recurring`, `recurrence_pattern_id`, and `next_occurrence`.
- Modified the database schema to reflect these changes, including alterations to existing columns and the removal of obsolete fields.
- Enhanced the expense creation logic to accommodate recurring expenses and updated related CRUD operations accordingly.
- Implemented necessary migrations to ensure database integrity and support for the new features.
2025-06-01 14:23:05 +02:00
98 changed files with 10420 additions and 1659 deletions

View File

@ -1,9 +1,11 @@
from logging.config import fileConfig
import os
import sys
import asyncio # Add this import
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine # Add this specific import
from alembic import context
@ -22,11 +24,11 @@ from app.config import settings # Import settings to get DATABASE_URL
config = context.config
# Set the sqlalchemy.url from your application settings
# Use a synchronous version of the URL for Alembic's operations
sync_db_url = settings.DATABASE_URL.replace("+asyncpg", "") if settings.DATABASE_URL else None
if not sync_db_url:
# Ensure DATABASE_URL is available and use it directly
if not settings.DATABASE_URL:
raise ValueError("DATABASE_URL not found in settings for Alembic.")
config.set_main_option('sqlalchemy.url', sync_db_url)
config.set_main_option('sqlalchemy.url', settings.DATABASE_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
@ -69,29 +71,37 @@ def run_migrations_offline() -> None:
context.run_migrations()
def run_migrations_online() -> None:
async def run_migrations_online_async() -> None: # Renamed and make async
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
# connectable here will be an AsyncEngine if the URL is asyncpg
db_url = config.get_main_option("sqlalchemy.url") # Get the async URL
if not db_url:
raise ValueError("Database URL is not configured in Alembic.")
with connectable.connect() as connection:
connectable = create_async_engine(db_url, poolclass=pool.NullPool)
async with connectable.connect() as connection: # Use async with
# Pass target_metadata to the run_sync callback
await connection.run_sync(do_run_migrations, target_metadata)
await connectable.dispose() # Dispose of the async engine
def do_run_migrations(connection, metadata):
"""Helper function to configure and run migrations within a sync callback."""
context.configure(
connection=connection, target_metadata=target_metadata
connection=connection,
target_metadata=metadata
# Include other options like compare_type=True, compare_server_default=True if needed
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
asyncio.run(run_migrations_online_async()) # Call the new async function

View File

@ -0,0 +1,347 @@
"""Initial schema setup
Revision ID: 0001_initial_schema
Revises:
Create Date: YYYY-MM-DD HH:MM:SS.ffffff
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '0001_initial_schema'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
user_role_enum = postgresql.ENUM('owner', 'member', name='userroleenum', create_type=False)
split_type_enum = postgresql.ENUM('EQUAL', 'EXACT_AMOUNTS', 'PERCENTAGE', 'SHARES', 'ITEM_BASED', name='splittypeenum', create_type=False)
expense_split_status_enum = postgresql.ENUM('unpaid', 'partially_paid', 'paid', name='expensesplitstatusenum', create_type=False)
expense_overall_status_enum = postgresql.ENUM('unpaid', 'partially_paid', 'paid', name='expenseoverallstatusenum', create_type=False)
recurrence_type_enum = postgresql.ENUM('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY', name='recurrencetypeenum', create_type=False)
chore_frequency_enum = postgresql.ENUM('one_time', 'daily', 'weekly', 'monthly', 'custom', name='chorefrequencyenum', create_type=False)
chore_type_enum = postgresql.ENUM('personal', 'group', name='choretypeenum', create_type=False)
def upgrade() -> None:
user_role_enum.create(op.get_bind(), checkfirst=True)
split_type_enum.create(op.get_bind(), checkfirst=True)
expense_split_status_enum.create(op.get_bind(), checkfirst=True)
expense_overall_status_enum.create(op.get_bind(), checkfirst=True)
recurrence_type_enum.create(op.get_bind(), checkfirst=True)
chore_frequency_enum.create(op.get_bind(), checkfirst=True)
chore_type_enum.create(op.get_bind(), checkfirst=True)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), server_default=sa.text('true'), nullable=False),
sa.Column('is_superuser', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('is_verified', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False)
op.create_table('groups',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_groups_id'), 'groups', ['id'], unique=False)
op.create_index(op.f('ix_groups_name'), 'groups', ['name'], unique=False)
op.create_table('user_groups',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('role', user_role_enum, nullable=False),
sa.Column('joined_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'group_id', name='uq_user_group')
)
op.create_index(op.f('ix_user_groups_id'), 'user_groups', ['id'], unique=False)
op.create_table('invites',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_active', sa.Boolean(), server_default=sa.text('true'), nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invites_code'), 'invites', ['code'], unique=False)
op.create_index('ix_invites_active_code', 'invites', ['code'], unique=True, postgresql_where=sa.text('is_active = true'))
op.create_index(op.f('ix_invites_id'), 'invites', ['id'], unique=False)
op.create_table('lists',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('is_complete', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('version', sa.Integer(), server_default='1', nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_lists_id'), 'lists', ['id'], unique=False)
op.create_index(op.f('ix_lists_name'), 'lists', ['name'], unique=False)
op.create_table('items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('list_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('quantity', sa.String(), nullable=True),
sa.Column('is_complete', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('added_by_id', sa.Integer(), nullable=False),
sa.Column('completed_by_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('version', sa.Integer(), server_default='1', nullable=False),
sa.ForeignKeyConstraint(['added_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['completed_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_items_id'), 'items', ['id'], unique=False)
op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False)
op.create_table('recurrence_patterns',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('type', recurrence_type_enum, nullable=False),
sa.Column('interval', sa.Integer(), server_default='1', nullable=False),
sa.Column('days_of_week', sa.String(), nullable=True),
sa.Column('end_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('max_occurrences', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_recurrence_patterns_id'), 'recurrence_patterns', ['id'], unique=False)
op.create_table('expenses',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('description', sa.String(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('currency', sa.String(), server_default='USD', nullable=False),
sa.Column('expense_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('split_type', split_type_enum, nullable=False),
sa.Column('list_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('item_id', sa.Integer(), nullable=True),
sa.Column('paid_by_user_id', sa.Integer(), nullable=False),
sa.Column('created_by_user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('version', sa.Integer(), server_default='1', nullable=False),
sa.Column('overall_settlement_status', expense_overall_status_enum, server_default='unpaid', nullable=False),
sa.Column('is_recurring', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('recurrence_pattern_id', sa.Integer(), nullable=True),
sa.Column('next_occurrence', sa.DateTime(timezone=True), nullable=True),
sa.Column('parent_expense_id', sa.Integer(), nullable=True),
sa.Column('last_occurrence', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.ForeignKeyConstraint(['item_id'], ['items.id'], ),
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ),
sa.ForeignKeyConstraint(['paid_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['parent_expense_id'], ['expenses.id'], ),
sa.ForeignKeyConstraint(['recurrence_pattern_id'], ['recurrence_patterns.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_expenses_created_by_user_id'), 'expenses', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_expenses_group_id'), 'expenses', ['group_id'], unique=False)
op.create_index(op.f('ix_expenses_id'), 'expenses', ['id'], unique=False)
op.create_index(op.f('ix_expenses_list_id'), 'expenses', ['list_id'], unique=False)
op.create_index(op.f('ix_expenses_paid_by_user_id'), 'expenses', ['paid_by_user_id'], unique=False)
op.create_index('ix_expenses_recurring_next_occurrence', 'expenses', ['is_recurring', 'next_occurrence'], unique=False, postgresql_where=sa.text('is_recurring = true'))
op.create_table('expense_splits',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('expense_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('owed_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('share_percentage', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('share_units', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('status', expense_split_status_enum, server_default='unpaid', nullable=False),
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['expense_id'], ['expenses.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split')
)
op.create_index(op.f('ix_expense_splits_id'), 'expense_splits', ['id'], unique=False)
op.create_index(op.f('ix_expense_splits_user_id'), 'expense_splits', ['user_id'], unique=False)
op.create_table('settlements',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('paid_by_user_id', sa.Integer(), nullable=False),
sa.Column('paid_to_user_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('settlement_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by_user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('version', sa.Integer(), server_default='1', nullable=False),
sa.CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.ForeignKeyConstraint(['paid_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['paid_to_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_settlements_created_by_user_id'), 'settlements', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_settlements_group_id'), 'settlements', ['group_id'], unique=False)
op.create_index(op.f('ix_settlements_id'), 'settlements', ['id'], unique=False)
op.create_index(op.f('ix_settlements_paid_by_user_id'), 'settlements', ['paid_by_user_id'], unique=False)
op.create_index(op.f('ix_settlements_paid_to_user_id'), 'settlements', ['paid_to_user_id'], unique=False)
op.create_table('settlement_activities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('expense_split_id', sa.Integer(), nullable=False),
sa.Column('paid_by_user_id', sa.Integer(), nullable=False),
sa.Column('paid_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('amount_paid', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('created_by_user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['expense_split_id'], ['expense_splits.id'], ),
sa.ForeignKeyConstraint(['paid_by_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_settlement_activity_created_by_user_id'), 'settlement_activities', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_settlement_activity_expense_split_id'), 'settlement_activities', ['expense_split_id'], unique=False)
op.create_index(op.f('ix_settlement_activity_paid_by_user_id'), 'settlement_activities', ['paid_by_user_id'], unique=False)
op.create_table('chores',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('type', chore_type_enum, nullable=False),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('frequency', chore_frequency_enum, nullable=False),
sa.Column('custom_interval_days', sa.Integer(), nullable=True),
sa.Column('next_due_date', sa.Date(), nullable=False),
sa.Column('last_completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chores_created_by_id'), 'chores', ['created_by_id'], unique=False)
op.create_index(op.f('ix_chores_group_id'), 'chores', ['group_id'], unique=False)
op.create_index(op.f('ix_chores_id'), 'chores', ['id'], unique=False)
op.create_index(op.f('ix_chores_name'), 'chores', ['name'], unique=False)
op.create_table('chore_assignments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chore_id', sa.Integer(), nullable=False),
sa.Column('assigned_to_user_id', sa.Integer(), nullable=False),
sa.Column('due_date', sa.Date(), nullable=False),
sa.Column('is_complete', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['assigned_to_user_id'], ['users.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['chore_id'], ['chores.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chore_assignments_assigned_to_user_id'), 'chore_assignments', ['assigned_to_user_id'], unique=False)
op.create_index(op.f('ix_chore_assignments_chore_id'), 'chore_assignments', ['chore_id'], unique=False)
op.create_index(op.f('ix_chore_assignments_id'), 'chore_assignments', ['id'], unique=False)
def downgrade() -> None:
op.drop_table('chore_assignments')
op.drop_index(op.f('ix_chores_name'), table_name='chores')
op.drop_index(op.f('ix_chores_id'), table_name='chores')
op.drop_index(op.f('ix_chores_group_id'), table_name='chores')
op.drop_index(op.f('ix_chores_created_by_id'), table_name='chores')
op.drop_table('chores')
op.drop_index(op.f('ix_settlement_activity_paid_by_user_id'), table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activity_expense_split_id'), table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activity_created_by_user_id'), table_name='settlement_activities')
op.drop_table('settlement_activities')
op.drop_index(op.f('ix_settlements_paid_to_user_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_paid_by_user_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_group_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_created_by_user_id'), table_name='settlements')
op.drop_table('settlements')
op.drop_index(op.f('ix_expense_splits_user_id'), table_name='expense_splits')
op.drop_index(op.f('ix_expense_splits_id'), table_name='expense_splits')
op.drop_table('expense_splits')
op.drop_index('ix_expenses_recurring_next_occurrence', table_name='expenses')
op.drop_index(op.f('ix_expenses_paid_by_user_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_list_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_group_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_created_by_user_id'), table_name='expenses')
op.drop_table('expenses')
op.drop_index(op.f('ix_recurrence_patterns_id'), table_name='recurrence_patterns')
op.drop_table('recurrence_patterns')
op.drop_index(op.f('ix_items_name'), table_name='items')
op.drop_index(op.f('ix_items_id'), table_name='items')
op.drop_table('items')
op.drop_index(op.f('ix_lists_name'), table_name='lists')
op.drop_index(op.f('ix_lists_id'), table_name='lists')
op.drop_table('lists')
op.drop_index('ix_invites_active_code', table_name='invites')
op.drop_index(op.f('ix_invites_id'), table_name='invites')
op.drop_index(op.f('ix_invites_code'), table_name='invites')
op.drop_table('invites')
op.drop_index(op.f('ix_user_groups_id'), table_name='user_groups')
op.drop_table('user_groups')
op.drop_index(op.f('ix_groups_name'), table_name='groups')
op.drop_index(op.f('ix_groups_id'), table_name='groups')
op.drop_table('groups')
op.drop_index(op.f('ix_users_name'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
chore_type_enum.drop(op.get_bind(), checkfirst=False)
chore_frequency_enum.drop(op.get_bind(), checkfirst=False)
recurrence_type_enum.drop(op.get_bind(), checkfirst=False)
expense_overall_status_enum.drop(op.get_bind(), checkfirst=False)
expense_split_status_enum.drop(op.get_bind(), checkfirst=False)
split_type_enum.drop(op.get_bind(), checkfirst=False)
user_role_enum.drop(op.get_bind(), checkfirst=False)

View File

@ -1,90 +0,0 @@
"""add_recurrence_pattern
Revision ID: 295cb070f266
Revises: 7cc1484074eb
Create Date: 2025-05-22 19:55:24.650524
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '295cb070f266'
down_revision: Union[str, None] = '7cc1484074eb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('expenses', 'next_occurrence',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
op.drop_index('ix_expenses_recurring_next_occurrence', table_name='expenses', postgresql_where='(is_recurring = true)')
op.drop_constraint('fk_expenses_recurrence_pattern_id', 'expenses', type_='foreignkey')
op.drop_constraint('fk_expenses_parent_expense_id', 'expenses', type_='foreignkey')
op.drop_column('expenses', 'recurrence_pattern_id')
op.drop_column('expenses', 'last_occurrence')
op.drop_column('expenses', 'parent_expense_id')
op.alter_column('recurrence_patterns', 'days_of_week',
existing_type=postgresql.JSON(astext_type=sa.Text()),
type_=sa.String(),
existing_nullable=True)
op.alter_column('recurrence_patterns', 'end_date',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
op.alter_column('recurrence_patterns', 'created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False)
op.alter_column('recurrence_patterns', 'updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False)
op.create_index(op.f('ix_settlement_activities_created_by_user_id'), 'settlement_activities', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_settlement_activities_expense_split_id'), 'settlement_activities', ['expense_split_id'], unique=False)
op.create_index(op.f('ix_settlement_activities_id'), 'settlement_activities', ['id'], unique=False)
op.create_index(op.f('ix_settlement_activities_paid_by_user_id'), 'settlement_activities', ['paid_by_user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_settlement_activities_paid_by_user_id'), table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activities_id'), table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activities_expense_split_id'), table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activities_created_by_user_id'), table_name='settlement_activities')
op.alter_column('recurrence_patterns', 'updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False)
op.alter_column('recurrence_patterns', 'created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False)
op.alter_column('recurrence_patterns', 'end_date',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
op.alter_column('recurrence_patterns', 'days_of_week',
existing_type=sa.String(),
type_=postgresql.JSON(astext_type=sa.Text()),
existing_nullable=True)
op.add_column('expenses', sa.Column('parent_expense_id', sa.INTEGER(), autoincrement=False, nullable=True))
op.add_column('expenses', sa.Column('last_occurrence', postgresql.TIMESTAMP(), autoincrement=False, nullable=True))
op.add_column('expenses', sa.Column('recurrence_pattern_id', sa.INTEGER(), autoincrement=False, nullable=True))
op.create_foreign_key('fk_expenses_parent_expense_id', 'expenses', 'expenses', ['parent_expense_id'], ['id'], ondelete='SET NULL')
op.create_foreign_key('fk_expenses_recurrence_pattern_id', 'expenses', 'recurrence_patterns', ['recurrence_pattern_id'], ['id'], ondelete='SET NULL')
op.create_index('ix_expenses_recurring_next_occurrence', 'expenses', ['is_recurring', 'next_occurrence'], unique=False, postgresql_where='(is_recurring = true)')
op.alter_column('expenses', 'next_occurrence',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
# ### end Alembic commands ###

View File

@ -1,42 +0,0 @@
"""Initial database schema
Revision ID: 5271d18372e5
Revises: 5e8b6dde50fc
Create Date: 2025-05-17 14:39:03.690180
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5271d18372e5'
down_revision: Union[str, None] = '5e8b6dde50fc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('expenses', sa.Column('created_by_user_id', sa.Integer(), nullable=False))
op.create_index(op.f('ix_expenses_created_by_user_id'), 'expenses', ['created_by_user_id'], unique=False)
op.create_foreign_key(None, 'expenses', 'users', ['created_by_user_id'], ['id'])
op.add_column('settlements', sa.Column('created_by_user_id', sa.Integer(), nullable=False))
op.create_index(op.f('ix_settlements_created_by_user_id'), 'settlements', ['created_by_user_id'], unique=False)
op.create_foreign_key(None, 'settlements', 'users', ['created_by_user_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'settlements', type_='foreignkey')
op.drop_index(op.f('ix_settlements_created_by_user_id'), table_name='settlements')
op.drop_column('settlements', 'created_by_user_id')
op.drop_constraint(None, 'expenses', type_='foreignkey')
op.drop_index(op.f('ix_expenses_created_by_user_id'), table_name='expenses')
op.drop_column('expenses', 'created_by_user_id')
# ### end Alembic commands ###

View File

@ -1,62 +0,0 @@
"""update_user_model_for_fastapi_users
Revision ID: 5e8b6dde50fc
Revises: 7c26d62e8005
Create Date: 2025-05-13 23:30:02.005611
"""
from typing import Sequence, Union
import secrets
from passlib.context import CryptContext
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5e8b6dde50fc'
down_revision: Union[str, None] = '7c26d62e8005'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Create password hasher
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Generate a secure random password and hash it
random_password = secrets.token_urlsafe(32) # 32 bytes of randomness
secure_hash = pwd_context.hash(random_password)
# 1. Add columns as nullable or with a default
op.add_column('users', sa.Column('hashed_password', sa.String(), nullable=True))
op.add_column('users', sa.Column('is_active', sa.Boolean(), nullable=True, server_default=sa.sql.expression.true()))
op.add_column('users', sa.Column('is_superuser', sa.Boolean(), nullable=True, server_default=sa.sql.expression.false()))
op.add_column('users', sa.Column('is_verified', sa.Boolean(), nullable=True, server_default=sa.sql.expression.false()))
# 2. Set default values for existing rows with secure hash
op.execute(f"UPDATE users SET hashed_password = '{secure_hash}' WHERE hashed_password IS NULL")
op.execute("UPDATE users SET is_active = true WHERE is_active IS NULL")
op.execute("UPDATE users SET is_superuser = false WHERE is_superuser IS NULL")
op.execute("UPDATE users SET is_verified = false WHERE is_verified IS NULL")
# 3. Alter columns to be non-nullable
op.alter_column('users', 'hashed_password', nullable=False)
op.alter_column('users', 'is_active', nullable=False)
op.alter_column('users', 'is_superuser', nullable=False)
op.alter_column('users', 'is_verified', nullable=False)
# 4. Drop the old column
op.drop_column('users', 'password_hash')
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('password_hash', sa.VARCHAR(), autoincrement=False, nullable=False))
op.drop_column('users', 'is_verified')
op.drop_column('users', 'is_superuser')
op.drop_column('users', 'is_active')
op.drop_column('users', 'hashed_password')
# ### end Alembic commands ###

View File

@ -1,32 +0,0 @@
"""Initial database schema
Revision ID: 5ed3ccbf05f7
Revises: 5271d18372e5
Create Date: 2025-05-17 14:40:52.165607
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5ed3ccbf05f7'
down_revision: Union[str, None] = '5271d18372e5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,60 +0,0 @@
"""add_missing_indexes_and_constraints
Revision ID: 7c26d62e8005
Revises: bc37e9c7ae19
Create Date: 2025-05-13 21:44:46.408395
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7c26d62e8005'
down_revision: Union[str, None] = 'bc37e9c7ae19'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_index('ix_expense_splits_user_id', 'expense_splits', ['user_id'], unique=False)
op.create_index(op.f('ix_expenses_group_id'), 'expenses', ['group_id'], unique=False)
op.create_index(op.f('ix_expenses_list_id'), 'expenses', ['list_id'], unique=False)
op.create_index(op.f('ix_expenses_paid_by_user_id'), 'expenses', ['paid_by_user_id'], unique=False)
op.create_index(op.f('ix_settlements_group_id'), 'settlements', ['group_id'], unique=False)
op.create_index(op.f('ix_settlements_paid_by_user_id'), 'settlements', ['paid_by_user_id'], unique=False)
op.create_index(op.f('ix_settlements_paid_to_user_id'), 'settlements', ['paid_to_user_id'], unique=False)
# Add check constraints
op.create_check_constraint(
'chk_expense_context',
'expenses',
'(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)'
)
op.create_check_constraint(
'chk_settlement_different_users',
'settlements',
'paid_by_user_id != paid_to_user_id'
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Drop check constraints
op.drop_constraint('chk_settlement_different_users', 'settlements', type_='check')
op.drop_constraint('chk_expense_context', 'expenses', type_='check')
op.drop_index(op.f('ix_settlements_paid_to_user_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_paid_by_user_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_group_id'), table_name='settlements')
op.drop_index(op.f('ix_expenses_paid_by_user_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_list_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_group_id'), table_name='expenses')
op.drop_index('ix_expense_splits_user_id', table_name='expense_splits')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""merge heads
Revision ID: 7cc1484074eb
Revises: add_recurring_expenses, e981855d0418
Create Date: 2025-05-22 16:11:32.030039
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7cc1484074eb'
down_revision: Union[str, None] = ('add_recurring_expenses', 'e981855d0418')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@ -1,32 +0,0 @@
"""check_models_alignment
Revision ID: 8efbdc779a76
Revises: 5ed3ccbf05f7
Create Date: 2025-05-17 15:03:08.242908
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8efbdc779a76'
down_revision: Union[str, None] = '5ed3ccbf05f7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,80 +0,0 @@
"""add recurring expenses
Revision ID: add_recurring_expenses
Revises: # You'll need to update this with your latest migration
Create Date: 2024-03-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'add_recurring_expenses'
down_revision = None # Update this with your latest migration
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create recurrence_patterns table
op.create_table(
'recurrence_patterns',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('type', sa.String(), nullable=False),
sa.Column('interval', sa.Integer(), nullable=False),
sa.Column('days_of_week', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('end_date', sa.DateTime(), nullable=True),
sa.Column('max_occurrences', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_recurrence_patterns_id'), 'recurrence_patterns', ['id'], unique=False)
# Add recurring expense columns to expenses table
op.add_column('expenses', sa.Column('is_recurring', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('expenses', sa.Column('next_occurrence', sa.DateTime(), nullable=True))
op.add_column('expenses', sa.Column('last_occurrence', sa.DateTime(), nullable=True))
op.add_column('expenses', sa.Column('recurrence_pattern_id', sa.Integer(), nullable=True))
op.add_column('expenses', sa.Column('parent_expense_id', sa.Integer(), nullable=True))
# Add foreign key constraints
op.create_foreign_key(
'fk_expenses_recurrence_pattern_id',
'expenses', 'recurrence_patterns',
['recurrence_pattern_id'], ['id'],
ondelete='SET NULL'
)
op.create_foreign_key(
'fk_expenses_parent_expense_id',
'expenses', 'expenses',
['parent_expense_id'], ['id'],
ondelete='SET NULL'
)
# Add indexes
op.create_index(
'ix_expenses_recurring_next_occurrence',
'expenses',
['is_recurring', 'next_occurrence'],
postgresql_where=sa.text('is_recurring = true')
)
def downgrade() -> None:
# Drop indexes
op.drop_index('ix_expenses_recurring_next_occurrence', table_name='expenses')
# Drop foreign key constraints
op.drop_constraint('fk_expenses_parent_expense_id', 'expenses', type_='foreignkey')
op.drop_constraint('fk_expenses_recurrence_pattern_id', 'expenses', type_='foreignkey')
# Drop columns from expenses table
op.drop_column('expenses', 'parent_expense_id')
op.drop_column('expenses', 'recurrence_pattern_id')
op.drop_column('expenses', 'last_occurrence')
op.drop_column('expenses', 'next_occurrence')
op.drop_column('expenses', 'is_recurring')
# Drop recurrence_patterns table
op.drop_index(op.f('ix_recurrence_patterns_id'), table_name='recurrence_patterns')
op.drop_table('recurrence_patterns')

View File

@ -1,191 +0,0 @@
"""fresh start
Revision ID: bc37e9c7ae19
Revises:
Create Date: 2025-05-08 16:06:51.208542
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bc37e9c7ae19'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('password_hash', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False)
op.create_table('groups',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_groups_id'), 'groups', ['id'], unique=False)
op.create_index(op.f('ix_groups_name'), 'groups', ['name'], unique=False)
op.create_table('invites',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_invites_active_code', 'invites', ['code'], unique=True, postgresql_where=sa.text('is_active = true'))
op.create_index(op.f('ix_invites_code'), 'invites', ['code'], unique=False)
op.create_index(op.f('ix_invites_id'), 'invites', ['id'], unique=False)
op.create_table('lists',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('is_complete', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('version', sa.Integer(), server_default='1', nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_lists_id'), 'lists', ['id'], unique=False)
op.create_index(op.f('ix_lists_name'), 'lists', ['name'], unique=False)
op.create_table('settlements',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('paid_by_user_id', sa.Integer(), nullable=False),
sa.Column('paid_to_user_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('settlement_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('version', sa.Integer(), server_default='1', nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.ForeignKeyConstraint(['paid_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['paid_to_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_settlements_id'), 'settlements', ['id'], unique=False)
op.create_table('user_groups',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('role', sa.Enum('owner', 'member', name='userroleenum'), nullable=False),
sa.Column('joined_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'group_id', name='uq_user_group')
)
op.create_index(op.f('ix_user_groups_id'), 'user_groups', ['id'], unique=False)
op.create_table('items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('list_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('quantity', sa.String(), nullable=True),
sa.Column('is_complete', sa.Boolean(), nullable=False),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('added_by_id', sa.Integer(), nullable=False),
sa.Column('completed_by_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('version', sa.Integer(), server_default='1', nullable=False),
sa.ForeignKeyConstraint(['added_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['completed_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_items_id'), 'items', ['id'], unique=False)
op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False)
op.create_table('expenses',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('description', sa.String(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('currency', sa.String(), nullable=False),
sa.Column('expense_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('split_type', sa.Enum('EQUAL', 'EXACT_AMOUNTS', 'PERCENTAGE', 'SHARES', 'ITEM_BASED', name='splittypeenum'), nullable=False),
sa.Column('list_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('item_id', sa.Integer(), nullable=True),
sa.Column('paid_by_user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('version', sa.Integer(), server_default='1', nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.ForeignKeyConstraint(['item_id'], ['items.id'], ),
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ),
sa.ForeignKeyConstraint(['paid_by_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_expenses_id'), 'expenses', ['id'], unique=False)
op.create_table('expense_splits',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('expense_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('owed_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('share_percentage', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('share_units', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['expense_id'], ['expenses.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split')
)
op.create_index(op.f('ix_expense_splits_id'), 'expense_splits', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_expense_splits_id'), table_name='expense_splits')
op.drop_table('expense_splits')
op.drop_index(op.f('ix_expenses_id'), table_name='expenses')
op.drop_table('expenses')
op.drop_index(op.f('ix_items_name'), table_name='items')
op.drop_index(op.f('ix_items_id'), table_name='items')
op.drop_table('items')
op.drop_index(op.f('ix_user_groups_id'), table_name='user_groups')
op.drop_table('user_groups')
op.drop_index(op.f('ix_settlements_id'), table_name='settlements')
op.drop_table('settlements')
op.drop_index(op.f('ix_lists_name'), table_name='lists')
op.drop_index(op.f('ix_lists_id'), table_name='lists')
op.drop_table('lists')
op.drop_index(op.f('ix_invites_id'), table_name='invites')
op.drop_index(op.f('ix_invites_code'), table_name='invites')
op.drop_index('ix_invites_active_code', table_name='invites', postgresql_where=sa.text('is_active = true'))
op.drop_table('invites')
op.drop_index(op.f('ix_groups_name'), table_name='groups')
op.drop_index(op.f('ix_groups_id'), table_name='groups')
op.drop_table('groups')
op.drop_index(op.f('ix_users_name'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View File

@ -1,82 +0,0 @@
"""add_settlement_activity_and_status_fields
Revision ID: e981855d0418
Revises: manual_0002
Create Date: 2025-05-22 02:13:06.419914
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'e981855d0418'
down_revision: Union[str, None] = 'manual_0002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Define Enum types for use in upgrade and downgrade
expense_split_status_enum = postgresql.ENUM('unpaid', 'partially_paid', 'paid', name='expensesplitstatusenum')
expense_overall_status_enum = postgresql.ENUM('unpaid', 'partially_paid', 'paid', name='expenseoverallstatusenum')
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Create ENUM types
expense_split_status_enum.create(op.get_bind(), checkfirst=True)
expense_overall_status_enum.create(op.get_bind(), checkfirst=True)
# Add 'overall_settlement_status' column to 'expenses' table
op.add_column('expenses', sa.Column('overall_settlement_status', expense_overall_status_enum, server_default='unpaid', nullable=False))
# Add 'status' and 'paid_at' columns to 'expense_splits' table
op.add_column('expense_splits', sa.Column('status', expense_split_status_enum, server_default='unpaid', nullable=False))
op.add_column('expense_splits', sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True))
# Create 'settlement_activities' table
op.create_table('settlement_activities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('expense_split_id', sa.Integer(), nullable=False),
sa.Column('paid_by_user_id', sa.Integer(), nullable=False),
sa.Column('paid_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('amount_paid', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('created_by_user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), # Removed onupdate for initial creation
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['expense_split_id'], ['expense_splits.id'], ),
sa.ForeignKeyConstraint(['paid_by_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_settlement_activity_created_by_user_id'), 'settlement_activities', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_settlement_activity_expense_split_id'), 'settlement_activities', ['expense_split_id'], unique=False)
op.create_index(op.f('ix_settlement_activity_paid_by_user_id'), 'settlement_activities', ['paid_by_user_id'], unique=False)
# Manually add onupdate trigger for updated_at as Alembic doesn't handle it well for all DBs
# For PostgreSQL, this is typically done via a trigger function.
# However, for simplicity in this migration, we rely on the application layer to update this field.
# Or, if using a database that supports it directly in Column definition (like some newer SQLAlch versions for certain backends):
# op.alter_column('settlement_activities', 'updated_at', server_default=sa.text('now()'), onupdate=sa.text('now()'))
# For now, the model has onupdate=func.now(), which SQLAlchemy ORM handles. The DDL here is for initial creation.
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_settlement_activity_paid_by_user_id'), table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activity_expense_split_id'), table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activity_created_by_user_id'), table_name='settlement_activities')
op.drop_table('settlement_activities')
op.drop_column('expense_splits', 'paid_at')
op.drop_column('expense_splits', 'status')
op.drop_column('expenses', 'overall_settlement_status')
# Drop ENUM types
expense_split_status_enum.drop(op.get_bind(), checkfirst=False)
expense_overall_status_enum.drop(op.get_bind(), checkfirst=False)
# ### end Alembic commands ###

View File

@ -1,78 +0,0 @@
"""manual_0001_add_chore_tables
Revision ID: manual_0001
Revises: 8efbdc779a76
Create Date: 2025-05-21 08:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'manual_0001'
down_revision: Union[str, None] = '8efbdc779a76' # Last real migration
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Enum definition for ChoreFrequencyEnum
chore_frequency_enum = postgresql.ENUM('one_time', 'daily', 'weekly', 'monthly', 'custom', name='chorefrequencyenum', create_type=False)
def upgrade() -> None:
"""Upgrade schema."""
# Create chorefrequencyenum type if it doesn't exist
connection = op.get_bind()
if not connection.dialect.has_type(connection, 'chorefrequencyenum'):
chore_frequency_enum.create(connection)
# Create chores table
op.create_table('chores',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('frequency', chore_frequency_enum, nullable=False),
sa.Column('custom_interval_days', sa.Integer(), nullable=True),
sa.Column('next_due_date', sa.Date(), nullable=False),
sa.Column('last_completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
)
# Create indexes for chores table
op.create_index('ix_chores_created_by_id', 'chores', ['created_by_id'], unique=False)
op.create_index('ix_chores_group_id', 'chores', ['group_id'], unique=False)
op.create_index('ix_chores_id', 'chores', ['id'], unique=False)
op.create_index('ix_chores_name', 'chores', ['name'], unique=False)
# Create chore_assignments table
op.create_table('chore_assignments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chore_id', sa.Integer(), nullable=False),
sa.Column('assigned_to_user_id', sa.Integer(), nullable=False),
sa.Column('due_date', sa.Date(), nullable=False),
sa.Column('is_complete', sa.Boolean(), server_default=sa.false(), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['assigned_to_user_id'], ['users.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['chore_id'], ['chores.id'], ondelete='CASCADE'),
)
# Create indexes for chore_assignments table
op.create_index('ix_chore_assignments_assigned_to_user_id', 'chore_assignments', ['assigned_to_user_id'], unique=False)
op.create_index('ix_chore_assignments_chore_id', 'chore_assignments', ['chore_id'], unique=False)
op.create_index('ix_chore_assignments_id', 'chore_assignments', ['id'], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table('chore_assignments')
op.drop_table('chores')
# Don't drop the enum type as it might be used by other tables

View File

@ -1,60 +0,0 @@
"""manual_0002_add_personal_chores
Revision ID: manual_0002
Revises: manual_0001
Create Date: 2025-05-22 08:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'manual_0002'
down_revision: Union[str, None] = 'manual_0001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Enum definition for ChoreTypeEnum
chore_type_enum = postgresql.ENUM('personal', 'group', name='choretypeenum', create_type=False)
def upgrade() -> None:
"""Upgrade schema."""
# Create choretypeenum type if it doesn't exist
connection = op.get_bind()
if not connection.dialect.has_type(connection, 'choretypeenum'):
chore_type_enum.create(connection)
# Add type column and make group_id nullable
op.add_column('chores', sa.Column('type', chore_type_enum, nullable=True))
op.alter_column('chores', 'group_id',
existing_type=sa.Integer(),
nullable=True,
existing_server_default=None
)
# Set default type for existing chores
op.execute("UPDATE chores SET type = 'group' WHERE type IS NULL")
# Make type column non-nullable after setting defaults
op.alter_column('chores', 'type',
existing_type=chore_type_enum,
nullable=False,
existing_server_default=None
)
def downgrade() -> None:
"""Downgrade schema."""
# Make group_id non-nullable again
op.alter_column('chores', 'group_id',
existing_type=sa.Integer(),
nullable=False,
existing_server_default=None
)
# Remove type column
op.drop_column('chores', 'type')
# Don't drop the enum type as it might be used by other tables

View File

@ -355,7 +355,3 @@ class PermissionDeniedError(HTTPException):
)
# Financials & Cost Splitting specific errors
class BalanceCalculationError(HTTPException):
# This class is not provided in the original file or the code block
# It's assumed to exist as it's called in the code block
pass

View File

@ -38,6 +38,8 @@ class SplitTypeEnum(enum.Enum):
PERCENTAGE = "PERCENTAGE" # Percentage for each user (defined in ExpenseSplit)
SHARES = "SHARES" # Proportional to shares/units (defined in ExpenseSplit)
ITEM_BASED = "ITEM_BASED" # If an expense is derived directly from item prices and who added them
# Consider renaming to a more generic term like 'DERIVED' or 'ENTITY_DRIVEN'
# if expenses might be derived from other entities in the future.
# Add more types as needed, e.g., UNPAID (for tracking debts not part of a formal expense)
class ExpenseSplitStatusEnum(enum.Enum):
@ -251,12 +253,16 @@ class Expense(Base):
group = relationship("Group", foreign_keys=[group_id], back_populates="expenses")
item = relationship("Item", foreign_keys=[item_id], back_populates="expenses")
splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan")
parent_expense = relationship("Expense", remote_side=[id], back_populates="child_expenses")
child_expenses = relationship("Expense", back_populates="parent_expense")
overall_settlement_status = Column(SAEnum(ExpenseOverallStatusEnum, name="expenseoverallstatusenum", create_type=True), nullable=False, server_default=ExpenseOverallStatusEnum.unpaid.value, default=ExpenseOverallStatusEnum.unpaid)
# --- Recurrence fields ---
is_recurring = Column(Boolean, default=False, nullable=False)
recurrence_pattern_id = Column(Integer, ForeignKey("recurrence_patterns.id"), nullable=True)
recurrence_pattern = relationship("RecurrencePattern", back_populates="expenses", uselist=False) # One-to-one
next_occurrence = Column(DateTime(timezone=True), nullable=True) # For recurring expenses
parent_expense_id = Column(Integer, ForeignKey("expenses.id"), nullable=True)
last_occurrence = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
# Ensure at least one context is provided

View File

@ -1,39 +0,0 @@
from sqlalchemy import Column, Integer, String, Numeric, DateTime, ForeignKey, Boolean, JSON, Enum as SQLEnum
from sqlalchemy.orm import relationship
from app.db.base_class import Base
from app.models.enums import SplitTypeEnum, ExpenseOverallStatusEnum, ExpenseSplitStatusEnum
class RecurrencePattern(Base):
__tablename__ = "recurrence_patterns"
id = Column(Integer, primary_key=True, index=True)
type = Column(String, nullable=False) # 'daily', 'weekly', 'monthly', 'yearly'
interval = Column(Integer, nullable=False)
days_of_week = Column(JSON, nullable=True) # For weekly recurrence
end_date = Column(DateTime, nullable=True)
max_occurrences = Column(Integer, nullable=True)
created_at = Column(DateTime, nullable=False)
updated_at = Column(DateTime, nullable=False)
# Relationship
expense = relationship("Expense", back_populates="recurrence_pattern", uselist=False)
class Expense(Base):
__tablename__ = "expenses"
# ... existing columns ...
# New columns for recurring expenses
is_recurring = Column(Boolean, default=False, nullable=False)
next_occurrence = Column(DateTime, nullable=True)
last_occurrence = Column(DateTime, nullable=True)
recurrence_pattern_id = Column(Integer, ForeignKey("recurrence_patterns.id"), nullable=True)
# New relationship
recurrence_pattern = relationship("RecurrencePattern", back_populates="expense", uselist=False)
generated_expenses = relationship("Expense",
backref=relationship("parent_expense", remote_side=[id]),
foreign_keys="Expense.parent_expense_id")
parent_expense_id = Column(Integer, ForeignKey("expenses.id"), nullable=True)
# ... rest of existing code ...

15
fe/package-lock.json generated
View File

@ -4420,6 +4420,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/date-fns": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz",
"integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",

View File

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

View File

@ -0,0 +1 @@
# This is a placeholder file to create the directory.

View File

@ -0,0 +1,127 @@
import { mount } from '@vue/test-utils';
import VAlert from './VAlert.vue';
import VIcon from './VIcon.vue'; // VAlert uses VIcon
import { nextTick } from 'vue';
// Mock VIcon
vi.mock('./VIcon.vue', () => ({
name: 'VIcon',
props: ['name'],
template: '<i :class="`mock-icon icon-${name}`"></i>',
}));
describe('VAlert.vue', () => {
it('renders message prop when no default slot', () => {
const messageText = 'This is a test alert.';
const wrapper = mount(VAlert, { props: { message: messageText } });
expect(wrapper.find('.alert-content').text()).toBe(messageText);
});
it('renders default slot content instead of message prop', () => {
const slotContent = '<strong>Custom Alert Content</strong>';
const wrapper = mount(VAlert, {
props: { message: 'Ignored message' },
slots: { default: slotContent },
});
expect(wrapper.find('.alert-content').html()).toContain(slotContent);
});
it('applies correct class based on type prop', () => {
const wrapperInfo = mount(VAlert, { props: { type: 'info' } });
expect(wrapperInfo.classes()).toContain('alert-info');
const wrapperSuccess = mount(VAlert, { props: { type: 'success' } });
expect(wrapperSuccess.classes()).toContain('alert-success');
const wrapperWarning = mount(VAlert, { props: { type: 'warning' } });
expect(wrapperWarning.classes()).toContain('alert-warning');
const wrapperError = mount(VAlert, { props: { type: 'error' } });
expect(wrapperError.classes()).toContain('alert-error');
});
it('shows close button and emits events when closable is true', async () => {
const wrapper = mount(VAlert, { props: { closable: true, modelValue: true } });
const closeButton = wrapper.find('.alert-close-btn');
expect(closeButton.exists()).toBe(true);
await closeButton.trigger('click');
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([false]);
expect(wrapper.emitted()['close']).toBeTruthy();
// Check if alert is hidden after internalModelValue updates (due to transition)
await nextTick(); // Allow internalModelValue to update and transition to start
// Depending on how transition is handled, the element might still be in DOM but display:none
// or completely removed. VAlert uses v-if, so it should be removed.
// Forcing internalModelValue directly for test simplicity if needed, or wait for transition.
// wrapper.vm.internalModelValue = false; // If directly manipulating for test
// await nextTick();
expect(wrapper.find('.alert').exists()).toBe(false); // After click and model update, it should be gone
});
it('does not show close button when closable is false (default)', () => {
const wrapper = mount(VAlert);
expect(wrapper.find('.alert-close-btn').exists()).toBe(false);
});
it('displays icon by default and uses type-specific default icons', () => {
const wrapperSuccess = mount(VAlert, { props: { type: 'success' } });
expect(wrapperSuccess.find('.alert-icon').exists()).toBe(true);
expect(wrapperSuccess.find('.icon-check-circle').exists()).toBe(true); // Mocked VIcon class
const wrapperError = mount(VAlert, { props: { type: 'error' } });
expect(wrapperError.find('.icon-alert-octagon').exists()).toBe(true);
});
it('displays custom icon when icon prop is provided', () => {
const customIconName = 'custom-bell';
const wrapper = mount(VAlert, { props: { icon: customIconName } });
expect(wrapper.find('.alert-icon').exists()).toBe(true);
expect(wrapper.find(`.icon-${customIconName}`).exists()).toBe(true);
});
it('does not display icon when showIcon is false', () => {
const wrapper = mount(VAlert, { props: { showIcon: false } });
expect(wrapper.find('.alert-icon').exists()).toBe(false);
});
it('renders actions slot content', () => {
const actionsContent = '<button>Retry</button>';
const wrapper = mount(VAlert, {
slots: { actions: actionsContent },
});
const actionsDiv = wrapper.find('.alert-actions');
expect(actionsDiv.exists()).toBe(true);
expect(actionsDiv.html()).toContain(actionsContent);
});
it('does not render .alert-actions if slot is not provided', () => {
const wrapper = mount(VAlert);
expect(wrapper.find('.alert-actions').exists()).toBe(false);
});
it('is visible by default (modelValue true)', () => {
const wrapper = mount(VAlert);
expect(wrapper.find('.alert').exists()).toBe(true);
});
it('is hidden when modelValue is initially false', () => {
const wrapper = mount(VAlert, { props: { modelValue: false } });
expect(wrapper.find('.alert').exists()).toBe(false);
});
it('updates visibility when modelValue prop changes', async () => {
const wrapper = mount(VAlert, { props: { modelValue: true } });
expect(wrapper.find('.alert').exists()).toBe(true);
await wrapper.setProps({ modelValue: false });
// Wait for transition if any, or internalModelValue update
await nextTick();
expect(wrapper.find('.alert').exists()).toBe(false);
await wrapper.setProps({ modelValue: true });
await nextTick();
expect(wrapper.find('.alert').exists()).toBe(true);
});
});

View File

@ -0,0 +1,245 @@
import VAlert from './VAlert.vue';
import VIcon from './VIcon.vue'; // Used by VAlert
import VButton from './VButton.vue'; // For actions slot
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref, watch } from 'vue';
const meta: Meta<typeof VAlert> = {
title: 'Valerie/VAlert',
component: VAlert,
tags: ['autodocs'],
argTypes: {
modelValue: { control: 'boolean', description: 'Controls alert visibility (v-model).' },
message: { control: 'text' },
type: { control: 'select', options: ['info', 'success', 'warning', 'error'] },
closable: { control: 'boolean' },
icon: { control: 'text', description: 'Custom VIcon name. Overrides default type-based icon.' },
showIcon: { control: 'boolean' },
// Events
'update:modelValue': { action: 'update:modelValue', table: { disable: true } },
close: { action: 'close' },
// Slots
default: { table: { disable: true } },
actions: { table: { disable: true } },
},
parameters: {
docs: {
description: {
component: 'An alert component to display contextual messages. Supports different types, icons, closable behavior, and custom actions.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VAlert>;
// Template for managing v-model in stories for dismissible alerts
const DismissibleAlertTemplate: Story = {
render: (args) => ({
components: { VAlert, VButton, VIcon }, // VIcon may not be needed directly in template if VAlert handles it
setup() {
const isVisible = ref(args.modelValue);
watch(() => args.modelValue, (newVal) => {
isVisible.value = newVal;
});
const onUpdateModelValue = (val: boolean) => {
isVisible.value = val;
// args.modelValue = val; // Update Storybook control
};
const resetAlert = () => { // Helper to show alert again in story
isVisible.value = true;
// args.modelValue = true;
};
return { ...args, isVisible, onUpdateModelValue, resetAlert };
},
template: `
<div>
<VAlert
:modelValue="isVisible"
@update:modelValue="onUpdateModelValue"
:message="message"
:type="type"
:closable="closable"
:icon="icon"
:showIcon="showIcon"
@close="() => $emit('close')"
>
<template #default v-if="args.customDefaultSlot">
<slot name="storyDefaultContent"></slot>
</template>
<template #actions v-if="args.showActionsSlot">
<slot name="storyActionsContent"></slot>
</template>
</VAlert>
<VButton v-if="!isVisible && closable" @click="resetAlert" size="sm" style="margin-top: 10px;">
Show Alert Again
</VButton>
</div>
`,
}),
};
export const Info: Story = {
...DismissibleAlertTemplate,
args: {
modelValue: true,
message: 'This is an informational alert. Check it out!',
type: 'info',
closable: false,
},
};
export const Success: Story = {
...DismissibleAlertTemplate,
args: {
modelValue: true,
message: 'Your operation was completed successfully.',
type: 'success',
closable: true,
},
};
export const Warning: Story = {
...DismissibleAlertTemplate,
args: {
modelValue: true,
message: 'Warning! Something might require your attention.',
type: 'warning',
closable: true,
},
};
export const ErrorAlert: Story = { // Renamed from 'Error' to avoid conflict with JS Error type
...DismissibleAlertTemplate,
args: {
modelValue: true,
message: 'An error occurred while processing your request.',
type: 'error',
closable: true,
},
};
export const Closable: Story = {
...DismissibleAlertTemplate,
args: {
...Info.args, // Start with info type
closable: true,
message: 'This alert can be closed by clicking the "x" button.',
},
};
export const WithCustomIcon: Story = {
...DismissibleAlertTemplate,
args: {
...Info.args,
icon: 'alert', // Example: using 'alert' icon from VIcon for an info message
message: 'This alert uses a custom icon ("alert").',
},
};
export const NoIcon: Story = {
...DismissibleAlertTemplate,
args: {
...Info.args,
showIcon: false,
message: 'This alert is displayed without any icon.',
},
};
export const CustomSlotContent: Story = {
...DismissibleAlertTemplate,
render: (args) => ({
components: { VAlert, VButton, VIcon },
setup() {
const isVisible = ref(args.modelValue);
watch(() => args.modelValue, (newVal) => { isVisible.value = newVal; });
const onUpdateModelValue = (val: boolean) => { isVisible.value = val; };
const resetAlert = () => { isVisible.value = true; };
return { ...args, isVisible, onUpdateModelValue, resetAlert };
},
template: `
<div>
<VAlert
:modelValue="isVisible"
@update:modelValue="onUpdateModelValue"
:type="type"
:closable="closable"
:icon="icon"
:showIcon="showIcon"
>
<h4>Custom Title via Slot!</h4>
<p>This is <strong>bold text</strong> and <em>italic text</em> inside the alert's default slot.</p>
<p>It overrides the 'message' prop.</p>
</VAlert>
<VButton v-if="!isVisible && closable" @click="resetAlert" size="sm" style="margin-top: 10px;">
Show Alert Again
</VButton>
</div>
`
}),
args: {
modelValue: true,
type: 'info',
closable: true,
customDefaultSlot: true, // Flag for template logic, not a prop of VAlert
// message prop is ignored due to slot
},
};
export const WithActions: Story = {
...DismissibleAlertTemplate,
render: (args) => ({
components: { VAlert, VButton, VIcon },
setup() {
const isVisible = ref(args.modelValue);
watch(() => args.modelValue, (newVal) => { isVisible.value = newVal; });
const onUpdateModelValue = (val: boolean) => { isVisible.value = val;};
const resetAlert = () => { isVisible.value = true; };
return { ...args, isVisible, onUpdateModelValue, resetAlert };
},
template: `
<div>
<VAlert
:modelValue="isVisible"
@update:modelValue="onUpdateModelValue"
:message="message"
:type="type"
:closable="closable"
>
<template #actions>
<VButton :variant="type === 'error' || type === 'warning' ? 'danger' : 'primary'" size="sm" @click="() => alert('Primary action clicked!')">
Take Action
</VButton>
<VButton variant="neutral" size="sm" @click="onUpdateModelValue(false)">
Dismiss
</VButton>
</template>
</VAlert>
<VButton v-if="!isVisible && (closable || args.showActionsSlot)" @click="resetAlert" size="sm" style="margin-top: 10px;">
Show Alert Again
</VButton>
</div>
`
}),
args: {
modelValue: true,
message: 'This alert has actions associated with it.',
type: 'warning',
closable: false, // Actions slot provides its own dismiss usually
showActionsSlot: true, // Flag for template logic
},
};
export const NotInitiallyVisible: Story = {
...DismissibleAlertTemplate,
args: {
...Success.args,
modelValue: false, // Start hidden
message: 'This alert is initially hidden and can be shown by the button below (or Storybook control).',
closable: true,
},
};

View File

@ -0,0 +1,184 @@
<template>
<Transition name="alert-fade">
<div v-if="internalModelValue" class="alert" :class="alertClasses" role="alert">
<div class="alert-main-section">
<VIcon v-if="showIcon && displayIconName" :name="displayIconName" class="alert-icon" />
<div class="alert-content">
<slot>{{ message }}</slot>
</div>
<button
v-if="closable"
type="button"
class="alert-close-btn"
aria-label="Close alert"
@click="handleClose"
>
<VIcon name="close" />
</button>
</div>
<div v-if="$slots.actions" class="alert-actions">
<slot name="actions"></slot>
</div>
</div>
</Transition>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon is available
const props = defineProps({
modelValue: { // For v-model for dismissible alerts
type: Boolean,
default: true,
},
message: {
type: String,
default: '',
},
type: {
type: String, // 'success', 'warning', 'error', 'info'
default: 'info',
validator: (value: string) => ['success', 'warning', 'error', 'info'].includes(value),
},
closable: {
type: Boolean,
default: false,
},
icon: { // Custom icon name
type: String,
default: null,
},
showIcon: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update:modelValue', 'close']);
// Internal state for visibility, to allow closing even if not using v-model
const internalModelValue = ref(props.modelValue);
watch(() => props.modelValue, (newVal) => {
internalModelValue.value = newVal;
});
const alertClasses = computed(() => [
`alert-${props.type}`,
// Add other classes based on props if needed, e.g., for icon presence
]);
const defaultIcons: Record<string, string> = {
success: 'check-circle',
warning: 'alert-triangle',
error: 'alert-octagon', // Or 'x-octagon' / 'alert-circle'
info: 'info-circle', // Or 'info' / 'bell'
};
const displayIconName = computed(() => {
if (!props.showIcon) return null;
return props.icon || defaultIcons[props.type] || 'info-circle'; // Fallback if type is somehow invalid
});
const handleClose = () => {
internalModelValue.value = false; // Hide it internally
emit('update:modelValue', false); // Emit for v-model
emit('close');
};
</script>
<style lang="scss" scoped>
.alert {
display: flex;
flex-direction: column;
padding: 0.75rem 1.25rem; // Default padding
margin-bottom: 1rem;
border: 1px solid transparent;
border-radius: 0.25rem; // Standard border radius
// Default alert type (info)
// These colors should align with valerie-ui.scss variables
color: #0c5460; // Example: $info-text
background-color: #d1ecf1; // Example: $info-bg
border-color: #bee5eb; // Example: $info-border
&.alert-success {
color: #155724; // $success-text
background-color: #d4edda; // $success-bg
border-color: #c3e6cb; // $success-border
}
&.alert-warning {
color: #856404; // $warning-text
background-color: #fff3cd; // $warning-bg
border-color: #ffeeba; // $warning-border
}
&.alert-error {
color: #721c24; // $danger-text
background-color: #f8d7da; // $danger-bg
border-color: #f5c6cb; // $danger-border
}
// Info is the default if no specific class matches
}
.alert-main-section {
display: flex;
align-items: flex-start; // Align icon with the start of the text
}
.alert-icon {
// margin-right: 0.75rem;
// font-size: 1.25em; // Make icon slightly larger than text
// Using VIcon size prop might be better.
// For now, let's assume VIcon itself has appropriate default sizing or takes it from font-size.
flex-shrink: 0; // Prevent icon from shrinking
margin-right: 0.8em;
margin-top: 0.1em; // Fine-tune vertical alignment with text
// Default icon color can be inherited or set explicitly if needed
// e.g., color: currentColor; (though VIcon might handle this)
}
.alert-content {
flex-grow: 1; // Message area takes available space
// line-height: 1.5; // Ensure good readability
}
.alert-close-btn {
background: transparent;
border: none;
color: inherit; // Inherit color from parent alert type for better contrast
opacity: 0.7;
padding: 0 0.5rem; // Minimal padding
margin-left: 1rem; // Space it from the content
font-size: 1.2rem; // Adjust VIcon size if needed via this
line-height: 1;
cursor: pointer;
&:hover {
opacity: 1;
}
// VIcon specific styling if needed
// ::v-deep(.icon) { font-size: 1em; }
}
.alert-actions {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(0,0,0,0.1); // Separator for actions
display: flex;
justify-content: flex-end; // Align actions to the right
gap: 0.5rem;
}
// Transition for fade in/out
.alert-fade-enter-active,
.alert-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.alert-fade-enter-from,
.alert-fade-leave-to {
opacity: 0;
transform: translateY(-10px); // Optional: slight slide effect
}
</style>

View File

@ -0,0 +1,115 @@
import { mount } from '@vue/test-utils';
import VAvatar from './VAvatar.vue';
import VIcon from './VIcon.vue'; // For testing slot content with an icon
import { describe, it, expect, vi } from 'vitest';
describe('VAvatar.vue', () => {
it('renders an image when src is provided', () => {
const src = 'https://via.placeholder.com/40';
const wrapper = mount(VAvatar, { props: { src, alt: 'Test Alt' } });
const img = wrapper.find('img');
expect(img.exists()).toBe(true);
expect(img.attributes('src')).toBe(src);
expect(img.attributes('alt')).toBe('Test Alt');
});
it('renders initials when src is not provided but initials are', () => {
const wrapper = mount(VAvatar, { props: { initials: 'JD' } });
expect(wrapper.find('img').exists()).toBe(false);
const initialsSpan = wrapper.find('.avatar-initials');
expect(initialsSpan.exists()).toBe(true);
expect(initialsSpan.text()).toBe('JD');
});
it('renders slot content when src and initials are not provided', () => {
const wrapper = mount(VAvatar, {
slots: {
default: '<VIcon name="user" />', // Using VIcon for a more realistic slot
},
global: {
components: { VIcon } // Register VIcon locally for this test
}
});
expect(wrapper.find('img').exists()).toBe(false);
expect(wrapper.find('.avatar-initials').exists()).toBe(false);
const icon = wrapper.findComponent(VIcon);
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe('user');
});
it('renders initials if image fails to load and initials are provided', async () => {
const wrapper = mount(VAvatar, {
props: { src: 'invalid-image-url.jpg', initials: 'FL' },
});
const img = wrapper.find('img');
expect(img.exists()).toBe(true); // Image still exists in DOM initially
// Trigger the error event on the image
await img.trigger('error');
// Now it should render initials
expect(wrapper.find('img').exists()).toBe(false); // This depends on implementation (v-if vs display:none)
// Current VAvatar.vue removes img with v-if
const initialsSpan = wrapper.find('.avatar-initials');
expect(initialsSpan.exists()).toBe(true);
expect(initialsSpan.text()).toBe('FL');
});
it('renders slot content if image fails to load and no initials are provided but slot is', async () => {
const wrapper = mount(VAvatar, {
props: { src: 'another-invalid.jpg' },
slots: { default: '<span>Fallback Slot</span>' },
});
const img = wrapper.find('img');
expect(img.exists()).toBe(true);
await img.trigger('error');
expect(wrapper.find('img').exists()).toBe(false); // Assuming v-if removes it
expect(wrapper.find('.avatar-initials').exists()).toBe(false);
expect(wrapper.text()).toBe('Fallback Slot');
});
it('renders nothing (or only .avatar div) if image fails, no initials, and no slot', async () => {
const wrapper = mount(VAvatar, {
props: { src: 'failure.jpg' },
});
const img = wrapper.find('img');
expect(img.exists()).toBe(true);
await img.trigger('error');
expect(wrapper.find('img').exists()).toBe(false);
expect(wrapper.find('.avatar-initials').exists()).toBe(false);
expect(wrapper.find('.avatar').element.innerHTML).toBe(''); // Check if it's empty
});
it('applies the base avatar class', () => {
const wrapper = mount(VAvatar, { props: { initials: 'T' } });
expect(wrapper.classes()).toContain('avatar');
});
it('uses the alt prop for the image', () => {
const wrapper = mount(VAvatar, { props: { src: 'image.png', alt: 'My Avatar' } });
expect(wrapper.find('img').attributes('alt')).toBe('My Avatar');
});
it('defaults alt prop to "Avatar"', () => {
const wrapper = mount(VAvatar, { props: { src: 'image.png' } });
expect(wrapper.find('img').attributes('alt')).toBe('Avatar');
});
it('resets image error state when src changes', async () => {
const wrapper = mount(VAvatar, {
props: { src: 'invalid.jpg', initials: 'IE' }
});
let img = wrapper.find('img');
await img.trigger('error'); // Image fails, initials should show
expect(wrapper.find('.avatar-initials').exists()).toBe(true);
await wrapper.setProps({ src: 'valid.jpg' }); // Change src to a new one
img = wrapper.find('img'); // Re-find img
expect(img.exists()).toBe(true); // Image should now be shown
expect(img.attributes('src')).toBe('valid.jpg');
expect(wrapper.find('.avatar-initials').exists()).toBe(false); // Initials should be hidden
});
});

View File

@ -0,0 +1,159 @@
import VAvatar from './VAvatar.vue';
import VIcon from './VIcon.vue'; // For slot example
import type { Meta, StoryObj } from '@storybook/vue3';
const meta: Meta<typeof VAvatar> = {
title: 'Valerie/VAvatar',
component: VAvatar,
tags: ['autodocs'],
argTypes: {
src: {
control: 'text',
description: 'URL to the avatar image. Invalid URLs will demonstrate fallback behavior if initials or slot are provided.',
},
initials: { control: 'text' },
alt: { control: 'text' },
// Slot content is best demonstrated via render functions or template strings in individual stories
},
parameters: {
docs: {
description: {
component: 'An avatar component that can display an image, initials, or custom content via a slot. Fallback order: src -> initials -> slot.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VAvatar>;
export const WithImage: Story = {
args: {
// Using a placeholder image service. Replace with a valid image URL for testing.
src: 'https://via.placeholder.com/40x40.png?text=IMG',
alt: 'User Avatar',
},
};
export const WithInitials: Story = {
args: {
initials: 'JD',
alt: 'User Initials',
},
};
export const WithSlotContent: Story = {
render: (args) => ({
components: { VAvatar, VIcon }, // VIcon for the example
setup() {
return { args };
},
template: `
<VAvatar v-bind="args">
<VIcon name="alert" style="font-size: 20px; color: #007AFF;" />
</VAvatar>
`,
}),
args: {
alt: 'Custom Icon Avatar',
},
parameters: {
docs: {
description: {
story: 'Avatar with custom content (e.g., an icon) passed through the default slot. This appears if `src` and `initials` are not provided or if `src` fails to load and `initials` are also absent.',
},
},
},
};
export const ImageErrorFallbackToInitials: Story = {
args: {
src: 'https://invalid-url-that-will-definitely-fail.jpg',
initials: 'ER',
alt: 'Error Fallback',
},
parameters: {
docs: {
description: {
story: 'Demonstrates fallback to initials when the image `src` is invalid or fails to load. The component attempts to load the image; upon error, it should display the initials.',
},
},
},
};
export const ImageErrorFallbackToSlot: Story = {
render: (args) => ({
components: { VAvatar, VIcon },
setup() { return { args }; },
template: `
<VAvatar v-bind="args">
<VIcon name="search" style="font-size: 20px; color: #6c757d;" />
</VAvatar>
`,
}),
args: {
src: 'https://another-invalid-url.png',
alt: 'Error Fallback to Slot',
// No initials provided, so it should fall back to slot content
},
parameters: {
docs: {
description: {
story: 'Demonstrates fallback to slot content when `src` is invalid and `initials` are not provided.',
},
},
},
};
export const OnlyInitialsNoSrc: Story = {
args: {
initials: 'AB',
alt: 'Initials Only',
},
};
export const EmptyPropsUsesSlot: Story = {
render: (args) => ({
components: { VAvatar },
setup() { return { args }; },
template: `
<VAvatar v-bind="args">
<span>?</span>
</VAvatar>
`,
}),
args: {
alt: 'Empty Avatar',
},
parameters: {
docs: {
description: {
story: 'When `src` and `initials` are not provided, the content from the default slot is rendered.',
},
},
},
};
export const LargerAvatarViaStyle: Story = {
args: {
initials: 'LG',
alt: 'Large Avatar',
},
decorators: [() => ({ template: '<div style="--avatar-size: 80px;"><story/></div>' })],
// This story assumes you might have CSS like:
// .avatar { width: var(--avatar-size, 40px); height: var(--avatar-size, 40px); }
// Or you'd pass a size prop if implemented. For now, it just wraps.
// A more direct approach for story:
render: (args) => ({
components: { VAvatar },
setup() { return { args }; },
template: `<VAvatar v-bind="args" style="width: 80px; height: 80px; font-size: 1.5em;" />`,
}),
parameters: {
docs: {
description: {
story: 'Avatars can be resized using standard CSS. The internal text/icon might also need adjustment for larger sizes if not handled by `em` units or percentages.',
},
},
},
};

View File

@ -0,0 +1,92 @@
<template>
<div class="avatar">
<img v-if="src" :src="src" :alt="alt" class="avatar-img" @error="handleImageError" />
<span v-else-if="initials" class="avatar-initials">{{ initials }}</span>
<slot v-else></slot>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch } from 'vue';
export default defineComponent({
name: 'VAvatar',
props: {
src: {
type: String,
default: null,
},
initials: {
type: String,
default: null,
},
alt: {
type: String,
default: 'Avatar',
},
},
setup(props, { slots }) {
// Optional: Handle image loading errors, e.g., to show initials or slot content as a fallback
const imageError = ref(false);
const handleImageError = () => {
imageError.value = true;
};
watch(() => props.src, (newSrc) => {
if (newSrc) {
imageError.value = false; // Reset error state when src changes
}
});
// This computed prop is not strictly necessary for the template logic above,
// but can be useful if template logic becomes more complex or for debugging.
const showImage = computed(() => props.src && !imageError.value);
const showInitials = computed(() => !showImage.value && props.initials);
const showSlot = computed(() => !showImage.value && !showInitials.value && slots.default);
return {
handleImageError,
// expose computed if needed by a more complex template
// showImage,
// showInitials,
// showSlot,
};
},
});
</script>
<style lang="scss" scoped>
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px; // Default size, can be made a prop or customized via CSS
height: 40px; // Default size
border-radius: 50%;
background-color: #E9ECEF; // Placeholder background, customize as needed (e.g., Gray-200)
color: #495057; // Placeholder text color (e.g., Gray-700)
font-weight: 500;
overflow: hidden; // Ensure content (like images) is clipped to the circle
vertical-align: middle; // Better alignment with surrounding text/elements
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover; // Ensures the image covers the area without distortion
}
.avatar-initials {
font-size: 0.9em; // Adjust based on avatar size and desired text appearance
line-height: 1; // Ensure initials are centered vertically
text-transform: uppercase;
}
// If using an icon via slot, you might want to style it too
// Example:
// ::v-deep(svg), ::v-deep(.icon) { // if slot contains an icon component or raw svg
// width: 60%;
// height: 60%;
// }
}
</style>

View File

@ -0,0 +1,61 @@
import { mount } from '@vue/test-utils';
import VBadge from './VBadge.vue';
import { describe, it, expect } from 'vitest';
describe('VBadge.vue', () => {
it('renders with required text prop and default variant', () => {
const wrapper = mount(VBadge, { props: { text: 'Test Badge' } });
expect(wrapper.text()).toBe('Test Badge');
expect(wrapper.classes()).toContain('item-badge');
expect(wrapper.classes()).toContain('badge-secondary'); // Default variant
});
it('renders with specified variant', () => {
const wrapper = mount(VBadge, {
props: { text: 'Accent Badge', variant: 'accent' },
});
expect(wrapper.classes()).toContain('badge-accent');
});
it('applies sticky class when variant is accent and sticky is true', () => {
const wrapper = mount(VBadge, {
props: { text: 'Sticky Accent', variant: 'accent', sticky: true },
});
expect(wrapper.classes()).toContain('badge-sticky');
});
it('does not apply sticky class when sticky is true but variant is not accent', () => {
const wrapper = mount(VBadge, {
props: { text: 'Sticky Secondary', variant: 'secondary', sticky: true },
});
expect(wrapper.classes()).not.toContain('badge-sticky');
});
it('does not apply sticky class when variant is accent but sticky is false', () => {
const wrapper = mount(VBadge, {
props: { text: 'Non-sticky Accent', variant: 'accent', sticky: false },
});
expect(wrapper.classes()).not.toContain('badge-sticky');
});
it('validates the variant prop', () => {
const validator = VBadge.props.variant.validator;
expect(validator('secondary')).toBe(true);
expect(validator('accent')).toBe(true);
expect(validator('settled')).toBe(true);
expect(validator('pending')).toBe(true);
expect(validator('invalid-variant')).toBe(false);
});
// Test for required prop 'text' (Vue Test Utils will warn if not provided)
it('Vue Test Utils should warn if required prop text is missing', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Mount without the required 'text' prop
// @ts-expect-error testing missing required prop
mount(VBadge, { props: { variant: 'accent'} });
// Check if Vue's warning about missing required prop was logged
// This depends on Vue's warning messages and might need adjustment
expect(spy.mock.calls.some(call => call[0].includes('[Vue warn]: Missing required prop: "text"'))).toBe(true);
spy.mockRestore();
});
});

View File

@ -0,0 +1,96 @@
import VBadge from './VBadge.vue';
import type { Meta, StoryObj } from '@storybook/vue3';
const meta: Meta<typeof VBadge> = {
title: 'Valerie/VBadge',
component: VBadge,
tags: ['autodocs'],
argTypes: {
text: { control: 'text' },
variant: {
control: 'select',
options: ['secondary', 'accent', 'settled', 'pending'],
},
sticky: { control: 'boolean' },
},
parameters: {
// Optional: Add notes about sticky behavior if it requires parent positioning
notes: 'The `sticky` prop adds a `badge-sticky` class when the variant is `accent`. Actual sticky positioning (e.g., `position: absolute` or `position: sticky`) should be handled by the parent component or additional global styles if needed, as the component itself does not enforce absolute positioning.',
},
};
export default meta;
type Story = StoryObj<typeof VBadge>;
export const Secondary: Story = {
args: {
text: 'Secondary',
variant: 'secondary',
},
};
export const Accent: Story = {
args: {
text: 'Accent',
variant: 'accent',
},
};
export const Settled: Story = {
args: {
text: 'Settled',
variant: 'settled',
},
};
export const Pending: Story = {
args: {
text: 'Pending',
variant: 'pending',
},
};
export const AccentSticky: Story = {
args: {
text: 'Sticky',
variant: 'accent',
sticky: true,
},
// To demonstrate the intended sticky positioning from the design,
// we can wrap the component in a relatively positioned div for the story.
decorators: [
() => ({
template: `
<div style="position: relative; width: 100px; height: 30px; border: 1px dashed #ccc; padding: 10px; margin-top: 10px; margin-left:10px;">
Parent Element
<story />
</div>
`,
}),
],
// parameters: {
// notes: 'This story demonstrates the sticky badge in a positioned context. The `badge-sticky` class itself only adds a border in this example. The absolute positioning shown (top: -4px, right: -4px relative to parent) needs to be applied by the parent or via styles targeting `.badge-sticky` in context.',
// }
// The following is a more direct way to show the absolute positioning if we add it to the component for the sticky case
// For now, the component itself doesn't add absolute positioning, so this shows how a parent might do it.
// If VBadge itself were to handle `position:absolute` when `sticky` and `variant=='accent'`, this would be different.
};
// Story to show that `sticky` prop only affects `accent` variant
export const StickyWithNonAccentVariant: Story = {
args: {
text: 'Not Sticky',
variant: 'secondary', // Not 'accent'
sticky: true,
},
parameters: {
notes: 'The `sticky` prop only applies the `.badge-sticky` class (and its associated styles like a border) when the variant is `accent`. For other variants, `sticky: true` has no visual effect on the badge itself.',
}
};
export const LongText: Story = {
args: {
text: 'This is a very long badge text',
variant: 'primary', // Assuming primary is a valid variant or falls back to default
},
};

View File

@ -0,0 +1,107 @@
<template>
<span :class="badgeClasses">{{ text }}</span>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
type BadgeVariant = 'secondary' | 'accent' | 'settled' | 'pending';
export default defineComponent({
name: 'VBadge',
props: {
text: {
type: String,
required: true,
},
variant: {
type: String as PropType<BadgeVariant>,
default: 'secondary',
validator: (value: string) => ['secondary', 'accent', 'settled', 'pending'].includes(value),
},
sticky: {
type: Boolean,
default: false,
},
},
setup(props) {
const badgeClasses = computed(() => {
const classes = [
'item-badge', // Base class from the design document
`badge-${props.variant}`,
];
// The design doc mentions: "Sticky: (Accent variant only)"
if (props.sticky && props.variant === 'accent') {
classes.push('badge-sticky');
}
return classes;
});
return {
badgeClasses,
};
},
});
</script>
<style lang="scss" scoped>
// Base styles from the design document
.item-badge {
display: inline-flex;
padding: 2px 8px; // 2px 8px from design
border-radius: 16px; // 16px from design
font-weight: 500; // Medium
font-size: 12px; // 12px from design
line-height: 16px; // 16px from design
text-align: center;
white-space: nowrap;
}
// Variants from the design document
.badge-secondary {
background-color: #E9ECEF; // Gray-100
color: #495057; // Gray-700
}
.badge-accent {
background-color: #E6F7FF; // Primary-50
color: #007AFF; // Primary-500
}
.badge-settled {
background-color: #E6F7F0; // Success-50 (assuming, based on typical color schemes)
color: #28A745; // Success-700 (assuming)
// Design doc has #198754 (Success-600) for text and #D1E7DD (Success-100) for background. Let's use those.
background-color: #D1E7DD;
color: #198754;
}
.badge-pending {
background-color: #FFF3E0; // Warning-50 (assuming)
color: #FFA500; // Warning-700 (assuming)
// Design doc has #FFC107 (Warning-500) for text and #FFF3CD (Warning-100) for background. Let's use those.
background-color: #FFF3CD;
color: #FFC107; // Note: Design shows a darker text #FFA000 (Warning-600 like) but specifies #FFC107 for the color name.
// Using #FFC107 for now, can be adjusted.
}
// Sticky style for Accent variant
.badge-sticky {
// The design doc implies sticky might mean position: sticky, or just a visual treatment.
// For now, let's assume it's a visual cue or a class that could be used with position: sticky by parent.
// If it refers to a specific visual change for the badge itself when sticky:
// e.g., a border, a shadow, or slightly different padding/look.
// The image shows it on top right, which is a positioning concern, not just a style of the badge itself.
// Let's add a subtle visual difference for the story, can be refined.
// For now, we'll assume 'badge-sticky' is a marker class and parent component handles actual stickiness.
// If it's meant to be position:absolute like in the Figma, that shouldn't be part of this component directly.
// The design doc description for "Sticky" under "Accent" variant says:
// "position: absolute; top: -4px; right: -4px;"
// This kind of positioning is usually context-dependent and best handled by the parent.
// However, if VBadge is *always* used this way when sticky, it could be added.
// For now, I will make `badge-sticky` only apply a visual change, not absolute positioning.
// The parent component can use this class to apply positioning.
// Example: add a small border to distinguish it slightly when sticky.
border: 1px solid #007AFF; // Primary-500 (same as text color for accent)
}
</style>

View File

@ -0,0 +1,159 @@
import { mount } from '@vue/test-utils';
import VButton from './VButton.vue';
import VIcon from './VIcon.vue'; // Import VIcon as it's a child component
import { describe, it, expect, vi } from 'vitest';
// Mock VIcon to simplify testing VButton in isolation if needed,
// or allow it to render if its behavior is simple and reliable.
// For now, we'll allow it to render as it's part of the visual output.
describe('VButton.vue', () => {
it('renders with default props', () => {
const wrapper = mount(VButton);
expect(wrapper.text()).toBe('Button');
expect(wrapper.classes()).toContain('btn');
expect(wrapper.classes()).toContain('btn-primary');
expect(wrapper.classes()).toContain('btn-md');
expect(wrapper.attributes('type')).toBe('button');
expect(wrapper.attributes('disabled')).toBeUndefined();
});
it('renders with specified label', () => {
const wrapper = mount(VButton, { props: { label: 'Click Me' } });
expect(wrapper.text()).toBe('Click Me');
});
it('renders with slot content', () => {
const wrapper = mount(VButton, {
slots: {
default: '<i>Slot Content</i>',
},
});
expect(wrapper.html()).toContain('<i>Slot Content</i>');
});
it('applies variant classes', () => {
const wrapper = mount(VButton, { props: { variant: 'secondary' } });
expect(wrapper.classes()).toContain('btn-secondary');
});
it('applies size classes', () => {
const wrapper = mount(VButton, { props: { size: 'sm' } });
expect(wrapper.classes()).toContain('btn-sm');
});
it('is disabled when disabled prop is true', () => {
const wrapper = mount(VButton, { props: { disabled: true } });
expect(wrapper.attributes('disabled')).toBeDefined();
expect(wrapper.classes()).toContain('btn-disabled');
});
it('emits click event when not disabled', async () => {
const handleClick = vi.fn();
const wrapper = mount(VButton, {
attrs: {
onClick: handleClick, // For native event handling by test runner
}
});
await wrapper.trigger('click');
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('emits click event via emits options when not disabled', async () => {
const wrapper = mount(VButton);
await wrapper.trigger('click');
expect(wrapper.emitted().click).toBeTruthy();
expect(wrapper.emitted().click.length).toBe(1);
});
it('does not emit click event when disabled', async () => {
const handleClick = vi.fn();
const wrapper = mount(VButton, {
props: { disabled: true },
attrs: {
onClick: handleClick, // For native event handling by test runner
}
});
await wrapper.trigger('click');
expect(handleClick).not.toHaveBeenCalled();
// Check emitted events from component as well
const wrapperEmitted = mount(VButton, { props: { disabled: true } });
await wrapperEmitted.trigger('click');
expect(wrapperEmitted.emitted().click).toBeUndefined();
});
it('renders left icon', () => {
const wrapper = mount(VButton, {
props: { iconLeft: 'search' },
// Global stubs or components might be needed if VIcon isn't registered globally for tests
global: { components: { VIcon } }
});
const icon = wrapper.findComponent(VIcon);
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe('search');
expect(wrapper.text()).toContain('Button'); // Label should still be there
});
it('renders right icon', () => {
const wrapper = mount(VButton, {
props: { iconRight: 'alert' },
global: { components: { VIcon } }
});
const icon = wrapper.findComponent(VIcon);
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe('alert');
});
it('renders icon only button', () => {
const wrapper = mount(VButton, {
props: { iconLeft: 'close', iconOnly: true, label: 'Close' },
global: { components: { VIcon } }
});
const icon = wrapper.findComponent(VIcon);
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe('close');
expect(wrapper.classes()).toContain('btn-icon-only');
// Label should be visually hidden but present for accessibility
const labelSpan = wrapper.find('span');
expect(labelSpan.exists()).toBe(true);
expect(labelSpan.classes()).toContain('sr-only');
expect(labelSpan.text()).toBe('Close');
});
it('renders icon only button with iconRight', () => {
const wrapper = mount(VButton, {
props: { iconRight: 'search', iconOnly: true, label: 'Search' },
global: { components: { VIcon } }
});
const icon = wrapper.findComponent(VIcon);
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe('search');
expect(wrapper.classes()).toContain('btn-icon-only');
});
it('validates variant prop', () => {
const validator = VButton.props.variant.validator;
expect(validator('primary')).toBe(true);
expect(validator('secondary')).toBe(true);
expect(validator('neutral')).toBe(true);
expect(validator('danger')).toBe(true);
expect(validator('invalid-variant')).toBe(false);
});
it('validates size prop', () => {
const validator = VButton.props.size.validator;
expect(validator('sm')).toBe(true);
expect(validator('md')).toBe(true);
expect(validator('lg')).toBe(true);
expect(validator('xl')).toBe(false);
});
it('validates type prop', () => {
const validator = VButton.props.type.validator;
expect(validator('button')).toBe(true);
expect(validator('submit')).toBe(true);
expect(validator('reset')).toBe(true);
expect(validator('link')).toBe(false);
});
});

View File

@ -0,0 +1,153 @@
import VButton from './VButton.vue';
import VIcon from './VIcon.vue'; // Import VIcon to ensure it's registered for stories if needed
import type { Meta, StoryObj } from '@storybook/vue3';
const meta: Meta<typeof VButton> = {
title: 'Valerie/VButton',
component: VButton,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
variant: {
control: 'select',
options: ['primary', 'secondary', 'neutral', 'danger'],
},
size: {
control: 'radio',
options: ['sm', 'md', 'lg'],
},
disabled: { control: 'boolean' },
iconLeft: {
control: 'select',
options: [null, 'alert', 'search', 'close'], // Example icons
},
iconRight: {
control: 'select',
options: [null, 'alert', 'search', 'close'], // Example icons
},
iconOnly: { control: 'boolean' },
type: {
control: 'select',
options: ['button', 'submit', 'reset'],
},
// Slot content is not easily controllable via args table in the same way for default slot
// We can use render functions or template strings in stories for complex slot content.
},
// Register VIcon globally for these stories if VButton doesn't always explicitly import/register it
// decorators: [() => ({ template: '<VIcon /><story/>' })], // This is one way, or ensure VButton registers it
};
export default meta;
type Story = StoryObj<typeof VButton>;
export const Primary: Story = {
args: {
label: 'Primary Button',
variant: 'primary',
},
};
export const Secondary: Story = {
args: {
label: 'Secondary Button',
variant: 'secondary',
},
};
export const Neutral: Story = {
args: {
label: 'Neutral Button',
variant: 'neutral',
},
};
export const Danger: Story = {
args: {
label: 'Danger Button',
variant: 'danger',
},
};
export const Small: Story = {
args: {
label: 'Small Button',
size: 'sm',
},
};
export const Large: Story = {
args: {
label: 'Large Button',
size: 'lg',
},
};
export const Disabled: Story = {
args: {
label: 'Disabled Button',
disabled: true,
},
};
export const WithIconLeft: Story = {
args: {
label: 'Icon Left',
iconLeft: 'search', // Example icon
},
};
export const WithIconRight: Story = {
args: {
label: 'Icon Right',
iconRight: 'alert', // Example icon
},
};
export const IconOnly: Story = {
args: {
label: 'Search', // Label for accessibility, will be visually hidden
iconLeft: 'search', // Or iconRight
iconOnly: true,
ariaLabel: 'Search Action', // It's good practice to ensure an aria-label for icon-only buttons
},
};
export const IconOnlySmall: Story = {
args: {
label: 'Close',
iconLeft: 'close',
iconOnly: true,
size: 'sm',
ariaLabel: 'Close Action',
},
};
export const WithCustomSlotContent: Story = {
render: (args) => ({
components: { VButton, VIcon },
setup() {
return { args };
},
template: `
<VButton v-bind="args">
<em>Italic Text</em> & <VIcon name="alert" size="sm" />
</VButton>
`,
}),
args: {
variant: 'primary',
// label is ignored when slot is used
},
};
export const AsSubmitButton: Story = {
args: {
label: 'Submit Form',
type: 'submit',
variant: 'primary',
iconLeft: 'alert', // Example, not typical for submit
},
// You might want to add a form in the story to see it in action
// decorators: [() => ({ template: '<form @submit.prevent="() => alert(\'Form Submitted!\')"><story/></form>' })],
};

View File

@ -0,0 +1,207 @@
<template>
<button
:type="type"
:class="buttonClasses"
:disabled="disabled"
@click="handleClick"
>
<VIcon v-if="iconLeft && !iconOnly" :name="iconLeft" :size="iconSize" class="mr-1" />
<VIcon v-if="iconOnly && (iconLeft || iconRight)" :name="iconNameForIconOnly" :size="iconSize" />
<span v-if="!iconOnly" :class="{ 'sr-only': iconOnly && (iconLeft || iconRight) }">
<slot>{{ label }}</slot>
</span>
<VIcon v-if="iconRight && !iconOnly" :name="iconRight" :size="iconSize" class="ml-1" />
</button>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon.vue is in the same directory
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg'; // Added 'lg' for consistency, though not in SCSS class list initially
type ButtonType = 'button' | 'submit' | 'reset';
export default defineComponent({
name: 'VButton',
components: {
VIcon,
},
props: {
label: {
type: String,
default: 'Button',
},
variant: {
type: String as PropType<ButtonVariant>,
default: 'primary',
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger'].includes(value),
},
size: {
type: String as PropType<ButtonSize>,
default: 'md',
validator: (value: string) => ['sm', 'md', 'lg'].includes(value),
},
disabled: {
type: Boolean,
default: false,
},
iconLeft: {
type: String,
default: null,
},
iconRight: {
type: String,
default: null,
},
iconOnly: {
type: Boolean,
default: false,
},
type: {
type: String as PropType<ButtonType>,
default: 'button',
validator: (value: string) => ['button', 'submit', 'reset'].includes(value),
},
},
emits: ['click'],
setup(props, { emit }) {
const buttonClasses = computed(() => {
const classes = [
'btn',
`btn-${props.variant}`,
`btn-${props.size}`,
];
if (props.iconOnly && (props.iconLeft || props.iconRight)) {
classes.push('btn-icon-only');
}
if (props.disabled) {
classes.push('btn-disabled'); // Assuming a general disabled class
}
return classes;
});
const iconSize = computed(() => {
// Adjust icon size based on button size, or define specific icon sizes
if (props.size === 'sm') return 'sm';
// if (props.size === 'lg') return 'lg'; // VIcon might not have lg, handle appropriately
return undefined; // VIcon default size
});
const iconNameForIconOnly = computed(() => {
return props.iconLeft || props.iconRight;
});
const handleClick = (event: MouseEvent) => {
if (!props.disabled) {
emit('click', event);
}
};
return {
buttonClasses,
iconSize,
iconNameForIconOnly,
handleClick,
};
},
});
</script>
<style lang="scss" scoped>
// Basic button styling - will be expanded in a later task
.btn {
padding: 0.5em 1em;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 500;
text-decoration: none;
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, color 0.2s ease-in-out;
&:hover {
opacity: 0.9;
}
&:active {
opacity: 0.7;
}
&.btn-disabled,
&[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
}
// Variants
.btn-primary {
background-color: #007bff; // Example color
color: white;
border-color: #007bff;
}
.btn-secondary {
background-color: #6c757d; // Example color
color: white;
border-color: #6c757d;
}
.btn-neutral {
background-color: #f8f9fa; // Example color
color: #212529;
border-color: #ced4da;
}
.btn-danger {
background-color: #dc3545; // Example color
color: white;
border-color: #dc3545;
}
// Sizes
.btn-sm {
padding: 0.25em 0.5em;
font-size: 0.875em;
}
.btn-md {
// Default size, styles are in .btn
}
.btn-lg {
padding: 0.6em 1.2em;
font-size: 1.125em;
}
// Icon only
.btn-icon-only {
padding: 0.5em; // Adjust padding for icon-only buttons
// Ensure VIcon fills the space or adjust VIcon size if needed
& .mr-1 { margin-right: 0 !important; } // Remove margin if accidentally applied
& .ml-1 { margin-left: 0 !important; } // Remove margin if accidentally applied
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// Margins for icons next to text (can be refined)
.mr-1 {
margin-right: 0.25em;
}
.ml-1 {
margin-left: 0.25em;
}
</style>

View File

@ -0,0 +1,140 @@
import { mount } from '@vue/test-utils';
import VCard from './VCard.vue';
import VIcon from './VIcon.vue'; // VCard uses VIcon
import { describe, it, expect, vi } from 'vitest';
// Mock VIcon to simplify testing VCard in isolation,
// especially if VIcon itself has complex rendering or external dependencies.
// vi.mock('./VIcon.vue', ()_ => ({
// name: 'VIcon',
// props: ['name', 'size'],
// template: '<i :class="`mock-icon icon-${name}`"></i>',
// }));
// For now, let's allow it to render as its props are simple.
describe('VCard.vue', () => {
// Default variant tests
describe('Default Variant', () => {
it('renders headerTitle when provided and no header slot', () => {
const headerText = 'My Card Header';
const wrapper = mount(VCard, { props: { headerTitle: headerText } });
const header = wrapper.find('.card-header');
expect(header.exists()).toBe(true);
expect(header.find('.card-header-title').text()).toBe(headerText);
});
it('renders header slot content instead of headerTitle', () => {
const slotContent = '<div class="custom-header">Custom Header</div>';
const wrapper = mount(VCard, {
props: { headerTitle: 'Ignored Title' },
slots: { header: slotContent },
});
const header = wrapper.find('.card-header');
expect(header.exists()).toBe(true);
expect(header.find('.custom-header').exists()).toBe(true);
expect(header.text()).toContain('Custom Header');
expect(header.find('.card-header-title').exists()).toBe(false);
});
it('does not render .card-header if no headerTitle and no header slot', () => {
const wrapper = mount(VCard, { slots: { default: '<p>Body</p>' } });
expect(wrapper.find('.card-header').exists()).toBe(false);
});
it('renders default slot content in .card-body', () => {
const bodyContent = '<p>Main card content here.</p>';
const wrapper = mount(VCard, { slots: { default: bodyContent } });
const body = wrapper.find('.card-body');
expect(body.exists()).toBe(true);
expect(body.html()).toContain(bodyContent);
});
it('renders footer slot content in .card-footer', () => {
const footerContent = '<span>Card Footer Text</span>';
const wrapper = mount(VCard, { slots: { footer: footerContent } });
const footer = wrapper.find('.card-footer');
expect(footer.exists()).toBe(true);
expect(footer.html()).toContain(footerContent);
});
it('does not render .card-footer if no footer slot', () => {
const wrapper = mount(VCard, { slots: { default: '<p>Body</p>' } });
expect(wrapper.find('.card-footer').exists()).toBe(false);
});
it('applies .card class by default', () => {
const wrapper = mount(VCard);
expect(wrapper.classes()).toContain('card');
expect(wrapper.classes()).not.toContain('empty-state-card');
});
});
// Empty state variant tests
describe('Empty State Variant', () => {
const emptyStateProps = {
variant: 'empty-state' as const,
emptyIcon: 'alert',
emptyTitle: 'Nothing to Show',
emptyMessage: 'There is no data available at this moment.',
};
it('applies .card and .empty-state-card classes', () => {
const wrapper = mount(VCard, { props: emptyStateProps });
expect(wrapper.classes()).toContain('card');
expect(wrapper.classes()).toContain('empty-state-card');
});
it('renders empty state icon, title, and message', () => {
const wrapper = mount(VCard, {
props: emptyStateProps,
global: { components: { VIcon } } // Ensure VIcon is available
});
const icon = wrapper.findComponent(VIcon); // Or find by class if not using findComponent
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe(emptyStateProps.emptyIcon);
expect(wrapper.find('.empty-state-title').text()).toBe(emptyStateProps.emptyTitle);
expect(wrapper.find('.empty-state-message').text()).toBe(emptyStateProps.emptyMessage);
});
it('does not render icon, title, message if props not provided', () => {
const wrapper = mount(VCard, {
props: { variant: 'empty-state' as const },
global: { components: { VIcon } }
});
expect(wrapper.findComponent(VIcon).exists()).toBe(false); // Or check for .empty-state-icon
expect(wrapper.find('.empty-state-title').exists()).toBe(false);
expect(wrapper.find('.empty-state-message').exists()).toBe(false);
});
it('renders empty-actions slot content', () => {
const actionsContent = '<button>Add Item</button>';
const wrapper = mount(VCard, {
props: emptyStateProps,
slots: { 'empty-actions': actionsContent },
});
const actionsContainer = wrapper.find('.empty-state-actions');
expect(actionsContainer.exists()).toBe(true);
expect(actionsContainer.html()).toContain(actionsContent);
});
it('does not render .empty-state-actions if slot is not provided', () => {
const wrapper = mount(VCard, { props: emptyStateProps });
expect(wrapper.find('.empty-state-actions').exists()).toBe(false);
});
it('does not render standard header, body (main slot), or footer in empty state', () => {
const wrapper = mount(VCard, {
props: { ...emptyStateProps, headerTitle: 'Should not show' },
slots: {
default: '<p>Standard body</p>',
footer: '<span>Standard footer</span>',
},
});
expect(wrapper.find('.card-header').exists()).toBe(false);
// The .card-body is used by empty-state-content, so check for specific standard content
expect(wrapper.text()).not.toContain('Standard body');
expect(wrapper.find('.card-footer').exists()).toBe(false);
});
});
});

View File

@ -0,0 +1,164 @@
import VCard from './VCard.vue';
import VIcon from './VIcon.vue'; // For empty state icon
import VButton from './VButton.vue'; // For empty state actions slot
import type { Meta, StoryObj } from '@storybook/vue3';
const meta: Meta<typeof VCard> = {
title: 'Valerie/VCard',
component: VCard,
tags: ['autodocs'],
argTypes: {
headerTitle: { control: 'text' },
variant: { control: 'select', options: ['default', 'empty-state'] },
emptyIcon: { control: 'text', if: { arg: 'variant', eq: 'empty-state' } },
emptyTitle: { control: 'text', if: { arg: 'variant', eq: 'empty-state' } },
emptyMessage: { control: 'text', if: { arg: 'variant', eq: 'empty-state' } },
// Slots are documented via story examples
header: { table: { disable: true } },
default: { table: { disable: true } },
footer: { table: { disable: true } },
'empty-actions': { table: { disable: true } },
},
parameters: {
docs: {
description: {
component: 'A versatile card component with support for header, body, footer, and an empty state variant.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VCard>;
export const DefaultWithAllSlots: Story = {
render: (args) => ({
components: { VCard, VButton },
setup() {
return { args };
},
template: `
<VCard :headerTitle="args.headerTitle" :variant="args.variant">
<template #header v-if="args.useCustomHeaderSlot">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>Custom Header Slot</span>
<VButton size="sm" variant="neutral">Action</VButton>
</div>
</template>
<p>This is the main body content of the card. It can contain any HTML or Vue components.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<template #footer v-if="args.useCustomFooterSlot">
<VButton variant="primary">Save Changes</VButton>
<VButton variant="neutral">Cancel</VButton>
</template>
</VCard>
`,
}),
args: {
headerTitle: 'Card Title (prop)',
variant: 'default',
useCustomHeaderSlot: false, // Control for story to switch between prop and slot
useCustomFooterSlot: true,
},
};
export const WithHeaderTitleAndFooterProp: Story = {
// This story will use headerTitle prop and a simple text footer via slot for demo
render: (args) => ({
components: { VCard },
setup() { return { args }; },
template: `
<VCard :headerTitle="args.headerTitle">
<p>Card body content goes here.</p>
<template #footer>
<p style="font-size: 0.9em; color: #555;">Simple footer text.</p>
</template>
</VCard>
`,
}),
args: {
headerTitle: 'Report Summary',
},
};
export const CustomHeaderAndFooterSlots: Story = {
...DefaultWithAllSlots, // Reuses render function from DefaultWithAllSlots
args: {
headerTitle: 'This will be overridden by slot', // Prop will be ignored due to slot
variant: 'default',
useCustomHeaderSlot: true,
useCustomFooterSlot: true,
},
};
export const BodyOnly: Story = {
render: (args) => ({
components: { VCard },
setup() { return { args }; },
template: `
<VCard>
<p>This card only has body content. No header or footer will be rendered.</p>
<p>It's useful for simple information display.</p>
</VCard>
`,
}),
args: {},
};
export const HeaderAndBody: Story = {
render: (args) => ({
components: { VCard },
setup() { return { args }; },
template: `
<VCard :headerTitle="args.headerTitle">
<p>This card has a header (via prop) and body content, but no footer.</p>
</VCard>
`,
}),
args: {
headerTitle: 'User Profile',
},
};
export const EmptyState: Story = {
render: (args) => ({
components: { VCard, VIcon, VButton }, // VIcon is used internally by VCard
setup() {
return { args };
},
template: `
<VCard
variant="empty-state"
:emptyIcon="args.emptyIcon"
:emptyTitle="args.emptyTitle"
:emptyMessage="args.emptyMessage"
>
<template #empty-actions v-if="args.showEmptyActions">
<VButton variant="primary" @click="() => alert('Add Item Clicked!')">Add New Item</VButton>
<VButton variant="neutral">Learn More</VButton>
</template>
</VCard>
`,
}),
args: {
variant: 'empty-state', // Already set, but good for clarity
emptyIcon: 'search', // Example icon name, ensure VIcon supports it or it's mocked
emptyTitle: 'No Items Found',
emptyMessage: 'There are currently no items to display. Try adjusting your filters or add a new item.',
showEmptyActions: true,
},
};
export const EmptyStateMinimal: Story = {
...EmptyState, // Reuses render function
args: {
variant: 'empty-state',
emptyIcon: '', // No icon
emptyTitle: 'Nothing Here',
emptyMessage: 'This space is intentionally blank.',
showEmptyActions: false, // No actions
},
};

View File

@ -0,0 +1,160 @@
<template>
<div :class="cardClasses">
<template v-if="variant === 'empty-state'">
<div class="card-body empty-state-content">
<VIcon v-if="emptyIcon" :name="emptyIcon" class="empty-state-icon" size="lg" />
<h3 v-if="emptyTitle" class="empty-state-title">{{ emptyTitle }}</h3>
<p v-if="emptyMessage" class="empty-state-message">{{ emptyMessage }}</p>
<div v-if="$slots['empty-actions']" class="empty-state-actions">
<slot name="empty-actions"></slot>
</div>
</div>
</template>
<template v-else>
<div v-if="$slots.header || headerTitle" class="card-header">
<slot name="header">
<h2 v-if="headerTitle" class="card-header-title">{{ headerTitle }}</h2>
</slot>
</div>
<div class="card-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon is in the same directory or globally registered
type CardVariant = 'default' | 'empty-state';
export default defineComponent({
name: 'VCard',
components: {
VIcon,
},
props: {
headerTitle: {
type: String,
default: null,
},
variant: {
type: String as PropType<CardVariant>,
default: 'default',
validator: (value: string) => ['default', 'empty-state'].includes(value),
},
// Empty state specific props
emptyIcon: {
type: String,
default: null,
},
emptyTitle: {
type: String,
default: null,
},
emptyMessage: {
type: String,
default: null,
},
},
setup(props) {
const cardClasses = computed(() => [
'card',
{ 'empty-state-card': props.variant === 'empty-state' },
]);
return {
cardClasses,
};
},
});
</script>
<style lang="scss" scoped>
.card {
background-color: #fff;
border: 1px solid #e0e0e0; // Example border color
border-radius: 0.375rem; // 6px, example
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); // Subtle shadow
display: flex;
flex-direction: column;
}
.card-header {
padding: 1rem 1.25rem; // Example padding
border-bottom: 1px solid #e0e0e0;
background-color: #f8f9fa; // Light background for header
.card-header-title {
margin: 0;
font-size: 1.25rem; // Larger font for header title
font-weight: 500;
}
// If using custom slot, ensure its content is styled appropriately
}
.card-body {
padding: 1.25rem; // Example padding
flex-grow: 1; // Allows body to expand if card has fixed height or content pushes footer
}
.card-footer {
padding: 1rem 1.25rem;
border-top: 1px solid #e0e0e0;
background-color: #f8f9fa; // Light background for footer
display: flex; // Useful for aligning items in the footer (e.g., buttons)
justify-content: flex-end; // Example: align buttons to the right
gap: 0.5rem; // Space between items in footer if multiple
}
// Empty state variant
.empty-state-card {
// Specific overall card styling for empty state if needed
// e.g. it might have a different border or background
// For now, it mainly affects the content layout via .empty-state-content
border-style: dashed; // Example: dashed border for empty state
border-color: #adb5bd;
}
.empty-state-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem 1.5rem; // More padding for empty state
min-height: 200px; // Ensure it has some presence
.empty-state-icon {
// VIcon's size prop is used, but we can add margin or color here
// font-size: 3rem; // If VIcon size prop wasn't sufficient
color: #6c757d; // Muted color for icon (e.g., Gray-600)
margin-bottom: 1rem;
}
.empty-state-title {
font-size: 1.5rem; // Larger title for empty state
font-weight: 500;
color: #343a40; // Darker color for title (e.g., Gray-800)
margin-top: 0;
margin-bottom: 0.5rem;
}
.empty-state-message {
font-size: 1rem;
color: #6c757d; // Muted color for message (e.g., Gray-600)
margin-bottom: 1.5rem;
max-width: 400px; // Constrain message width for readability
}
.empty-state-actions {
display: flex;
gap: 0.75rem; // Space between action buttons
// Buttons inside will be styled by VButton or other button components
}
}
</style>

View File

@ -0,0 +1,94 @@
import { mount } from '@vue/test-utils';
import VCheckbox from './VCheckbox.vue';
import { describe, it, expect } from 'vitest';
describe('VCheckbox.vue', () => {
it('binds modelValue and emits update:modelValue correctly (v-model)', async () => {
const wrapper = mount(VCheckbox, {
props: { modelValue: false, id: 'test-check' }, // id is required due to default prop
});
const inputElement = wrapper.find('input[type="checkbox"]');
// Check initial state (unchecked)
expect(inputElement.element.checked).toBe(false);
// Simulate user checking the box
await inputElement.setChecked(true);
// Check emitted event
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([true]);
// Simulate parent v-model update (checked)
await wrapper.setProps({ modelValue: true });
expect(inputElement.element.checked).toBe(true);
// Simulate user unchecking the box
await inputElement.setChecked(false);
expect(wrapper.emitted()['update:modelValue'][1]).toEqual([false]);
// Simulate parent v-model update (unchecked)
await wrapper.setProps({ modelValue: false });
expect(inputElement.element.checked).toBe(false);
});
it('renders label when label prop is provided', () => {
const labelText = 'Subscribe to newsletter';
const wrapper = mount(VCheckbox, {
props: { modelValue: false, label: labelText, id: 'newsletter-check' },
});
const labelElement = wrapper.find('.checkbox-text-label');
expect(labelElement.exists()).toBe(true);
expect(labelElement.text()).toBe(labelText);
});
it('does not render text label span when label prop is not provided', () => {
const wrapper = mount(VCheckbox, {
props: { modelValue: false, id: 'no-label-check' },
});
expect(wrapper.find('.checkbox-text-label').exists()).toBe(false);
});
it('is disabled when disabled prop is true', () => {
const wrapper = mount(VCheckbox, {
props: { modelValue: false, disabled: true, id: 'disabled-check' },
});
expect(wrapper.find('input[type="checkbox"]').attributes('disabled')).toBeDefined();
expect(wrapper.find('.checkbox-label').classes()).toContain('disabled');
});
it('is not disabled by default', () => {
const wrapper = mount(VCheckbox, { props: { modelValue: false, id: 'enabled-check' } });
expect(wrapper.find('input[type="checkbox"]').attributes('disabled')).toBeUndefined();
expect(wrapper.find('.checkbox-label').classes()).not.toContain('disabled');
});
it('passes id prop to the input element and label for attribute', () => {
const checkboxId = 'my-custom-checkbox-id';
const wrapper = mount(VCheckbox, {
props: { modelValue: false, id: checkboxId },
});
expect(wrapper.find('input[type="checkbox"]').attributes('id')).toBe(checkboxId);
expect(wrapper.find('.checkbox-label').attributes('for')).toBe(checkboxId);
});
it('generates an id if not provided', () => {
const wrapper = mount(VCheckbox, { props: { modelValue: false } });
const inputId = wrapper.find('input[type="checkbox"]').attributes('id');
expect(inputId).toBeDefined();
expect(inputId).toContain('vcheckbox-');
expect(wrapper.find('.checkbox-label').attributes('for')).toBe(inputId);
});
it('contains a .checkmark span', () => {
const wrapper = mount(VCheckbox, { props: { modelValue: false, id: 'checkmark-check' } });
expect(wrapper.find('.checkmark').exists()).toBe(true);
});
it('root element is a label with .checkbox-label class', () => {
const wrapper = mount(VCheckbox, { props: { modelValue: false, id: 'root-check' } });
expect(wrapper.element.tagName).toBe('LABEL');
expect(wrapper.classes()).toContain('checkbox-label');
});
});

View File

@ -0,0 +1,151 @@
import VCheckbox from './VCheckbox.vue';
import VFormField from './VFormField.vue'; // For context, though checkbox usually handles its own label
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref, watch } from 'vue'; // For v-model in stories
const meta: Meta<typeof VCheckbox> = {
title: 'Valerie/VCheckbox',
component: VCheckbox,
tags: ['autodocs'],
argTypes: {
modelValue: { control: 'boolean', description: 'Bound state using v-model.' },
label: { control: 'text' },
disabled: { control: 'boolean' },
id: { control: 'text' },
// 'update:modelValue': { action: 'updated' }
},
parameters: {
docs: {
description: {
component: 'A custom checkbox component with support for v-model, labels, and disabled states.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VCheckbox>;
// Template for v-model interaction in stories
const VModelTemplate: Story = {
render: (args) => ({
components: { VCheckbox },
setup() {
const storyValue = ref(args.modelValue);
watch(() => args.modelValue, (newVal) => {
storyValue.value = newVal;
});
const onChange = (newValue: boolean) => {
storyValue.value = newValue;
// args.modelValue = newValue; // Storybook controls should update this
}
return { args, storyValue, onChange };
},
template: '<VCheckbox v-bind="args" :modelValue="storyValue" @update:modelValue="onChange" />',
}),
};
export const Basic: Story = {
...VModelTemplate,
args: {
id: 'basicCheckbox',
modelValue: false,
},
};
export const WithLabel: Story = {
...VModelTemplate,
args: {
id: 'labelledCheckbox',
modelValue: true,
label: 'Accept terms and conditions',
},
};
export const DisabledUnchecked: Story = {
...VModelTemplate,
args: {
id: 'disabledUncheckedCheckbox',
modelValue: false,
label: 'Cannot select this',
disabled: true,
},
};
export const DisabledChecked: Story = {
...VModelTemplate,
args: {
id: 'disabledCheckedCheckbox',
modelValue: true,
label: 'Cannot unselect this',
disabled: true,
},
};
export const NoLabel: Story = {
...VModelTemplate,
args: {
id: 'noLabelCheckbox',
modelValue: true,
// No label prop
},
parameters: {
docs: {
description: { story: 'Checkbox without a visible label prop. An external label can be associated using its `id`.' },
},
},
};
// VCheckbox is usually self-contained with its label.
// Using it in VFormField might be less common unless VFormField is used for error messages only.
export const InFormFieldForError: Story = {
render: (args) => ({
components: { VCheckbox, VFormField },
setup() {
const storyValue = ref(args.checkboxArgs.modelValue);
watch(() => args.checkboxArgs.modelValue, (newVal) => {
storyValue.value = newVal;
});
const onChange = (newValue: boolean) => {
storyValue.value = newValue;
}
// VFormField's label prop is not used here as VCheckbox has its own.
// VFormField's `forId` would match VCheckbox's `id`.
return { args, storyValue, onChange };
},
template: `
<VFormField :errorMessage="args.formFieldArgs.errorMessage">
<VCheckbox
v-bind="args.checkboxArgs"
:modelValue="storyValue"
@update:modelValue="onChange"
/>
</VFormField>
`,
}),
args: {
formFieldArgs: {
errorMessage: 'This selection is required.',
// No label for VFormField here, VCheckbox provides its own
},
checkboxArgs: {
id: 'formFieldCheckbox',
modelValue: false,
label: 'I agree to the terms',
},
},
parameters: {
docs: {
description: { story: '`VCheckbox` used within `VFormField`, primarily for displaying an error message associated with the checkbox. VCheckbox manages its own label.' },
},
},
};
export const PreChecked: Story = {
...VModelTemplate,
args: {
id: 'preCheckedCheckbox',
modelValue: true,
label: 'This starts checked',
},
};

View File

@ -0,0 +1,146 @@
<template>
<label :class="labelClasses" :for="id">
<input
type="checkbox"
:id="id"
:checked="modelValue"
:disabled="disabled"
@change="onChange"
/>
<span class="checkmark"></span>
<span v-if="label" class="checkbox-text-label">{{ label }}</span>
</label>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
export default defineComponent({
name: 'VCheckbox',
props: {
modelValue: {
type: Boolean,
required: true,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
id: {
type: String,
default: () => `vcheckbox-${Math.random().toString(36).substring(2, 9)}`, // Auto-generate ID if not provided
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const labelClasses = computed(() => [
'checkbox-label',
{ 'disabled': props.disabled },
]);
const onChange = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.checked);
};
return {
labelClasses,
onChange,
};
},
});
</script>
<style lang="scss" scoped>
.checkbox-label {
display: inline-flex; // Changed from block to inline-flex for better alignment with other form elements if needed
align-items: center;
cursor: pointer;
position: relative;
user-select: none; // Prevent text selection on click
padding-left: 28px; // Space for the custom checkmark
min-height: 20px; // Ensure consistent height, matches checkmark size + border
font-size: 1rem; // Default font size, can be inherited or customized
// Hide the default browser checkbox
input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
// Custom checkmark
.checkmark {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
height: 20px;
width: 20px;
background-color: #fff; // Default background
border: 1px solid #adb5bd; // Default border (e.g., Gray-400)
border-radius: 0.25rem; // Rounded corners
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
// Checkmark symbol (hidden when not checked)
&:after {
content: "";
position: absolute;
display: none;
left: 6px;
top: 2px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
// When checkbox is checked
input[type="checkbox"]:checked ~ .checkmark {
background-color: #007bff; // Checked background (e.g., Primary color)
border-color: #007bff;
}
input[type="checkbox"]:checked ~ .checkmark:after {
display: block; // Show checkmark symbol
}
// Focus state (accessibility) - style the custom checkmark
input[type="checkbox"]:focus ~ .checkmark {
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); // Focus ring like Bootstrap
}
// Disabled state
&.disabled {
cursor: not-allowed;
opacity: 0.7; // Dim the entire label including text
input[type="checkbox"]:disabled ~ .checkmark {
background-color: #e9ecef; // Disabled background (e.g., Gray-200)
border-color: #ced4da; // Disabled border (e.g., Gray-300)
}
input[type="checkbox"]:disabled:checked ~ .checkmark {
background-color: #7badec; // Lighter primary for disabled checked state
border-color: #7badec;
}
}
.checkbox-text-label {
margin-left: 0.5rem; // Space between checkmark and text label (if checkmark is not absolute or padding-left on root is used)
// With absolute checkmark and padding-left on root, this might not be needed or adjusted.
// Given current setup (padding-left: 28px on root), this provides additional space if label text is present.
// If checkmark was part of the flex flow, this would be more critical.
// Let's adjust to ensure it's always to the right of the 28px padded area.
vertical-align: middle; // Align text with the (conceptual) middle of the checkmark
}
}
</style>

View File

@ -0,0 +1,112 @@
import { mount } from '@vue/test-utils';
import VFormField from './VFormField.vue';
import { describe, it, expect } from 'vitest';
// Simple placeholder for slotted input content in tests
const TestInputComponent = {
template: '<input id="test-input" type="text" />',
props: ['id'], // Accept id if needed to match label's `for`
};
describe('VFormField.vue', () => {
it('renders default slot content', () => {
const wrapper = mount(VFormField, {
slots: {
default: '<input type="text" id="my-input" />',
},
});
const input = wrapper.find('input[type="text"]');
expect(input.exists()).toBe(true);
expect(input.attributes('id')).toBe('my-input');
});
it('renders a label when label prop is provided', () => {
const labelText = 'Username';
const wrapper = mount(VFormField, {
props: { label: labelText, forId: 'user-input' },
slots: { default: '<input id="user-input" />' }
});
const label = wrapper.find('label');
expect(label.exists()).toBe(true);
expect(label.text()).toBe(labelText);
expect(label.attributes('for')).toBe('user-input');
expect(label.classes()).toContain('form-label');
});
it('does not render a label when label prop is not provided', () => {
const wrapper = mount(VFormField, {
slots: { default: '<input />' }
});
expect(wrapper.find('label').exists()).toBe(false);
});
it('renders an error message when errorMessage prop is provided', () => {
const errorText = 'This field is required.';
const wrapper = mount(VFormField, {
props: { errorMessage: errorText },
slots: { default: '<input />' }
});
const errorMessage = wrapper.find('.form-error-message');
expect(errorMessage.exists()).toBe(true);
expect(errorMessage.text()).toBe(errorText);
});
it('does not render an error message when errorMessage prop is not provided', () => {
const wrapper = mount(VFormField, {
slots: { default: '<input />' }
});
expect(wrapper.find('.form-error-message').exists()).toBe(false);
});
it('applies the forId prop to the label\'s "for" attribute', () => {
const inputId = 'email-field';
const wrapper = mount(VFormField, {
props: { label: 'Email', forId: inputId },
slots: { default: `<input id="${inputId}" />` }
});
const label = wrapper.find('label');
expect(label.attributes('for')).toBe(inputId);
});
it('label "for" attribute is present even if forId is null or undefined, but empty', () => {
// Vue typically removes attributes if their value is null/undefined.
// Let's test the behavior. If forId is not provided, 'for' shouldn't be on the label.
const wrapperNull = mount(VFormField, {
props: { label: 'Test', forId: null },
slots: { default: '<input />' }
});
const labelNull = wrapperNull.find('label');
expect(labelNull.attributes('for')).toBeUndefined(); // Or it might be an empty string depending on Vue version/handling
const wrapperUndefined = mount(VFormField, {
props: { label: 'Test' }, // forId is undefined
slots: { default: '<input />' }
});
const labelUndefined = wrapperUndefined.find('label');
expect(labelUndefined.attributes('for')).toBeUndefined();
});
it('applies the .form-group class to the root element', () => {
const wrapper = mount(VFormField, {
slots: { default: '<input />' }
});
expect(wrapper.classes()).toContain('form-group');
});
it('renders label, input, and error message all together', () => {
const wrapper = mount(VFormField, {
props: {
label: 'Password',
forId: 'pass',
errorMessage: 'Too short'
},
slots: {
default: '<input type="password" id="pass" />'
}
});
expect(wrapper.find('label').exists()).toBe(true);
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
expect(wrapper.find('.form-error-message').exists()).toBe(true);
});
});

View File

@ -0,0 +1,135 @@
import VFormField from './VFormField.vue';
import type { Meta, StoryObj } from '@storybook/vue3';
// A simple placeholder input component for demonstration purposes in stories
const VInputPlaceholder = {
template: '<input :id="id" type="text" :placeholder="placeholder" style="border: 1px solid #ccc; padding: 0.5em; border-radius: 4px; width: 100%;" />',
props: ['id', 'placeholder'],
};
const meta: Meta<typeof VFormField> = {
title: 'Valerie/VFormField',
component: VFormField,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
forId: { control: 'text', description: 'ID of the input element this label is for. Should match the id of the slotted input.' },
errorMessage: { control: 'text' },
// Default slot is not directly configurable via args table in a simple way,
// so we use render functions or template strings in stories.
},
parameters: {
docs: {
description: {
component: 'A wrapper component to structure form fields with a label, the input element itself (via slot), and an optional error message.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VFormField>;
export const WithLabelAndInput: Story = {
render: (args) => ({
components: { VFormField, VInputPlaceholder },
setup() {
return { args };
},
template: `
<VFormField :label="args.label" :forId="args.forId">
<VInputPlaceholder id="nameInput" placeholder="Enter your name" />
</VFormField>
`,
}),
args: {
label: 'Full Name',
forId: 'nameInput', // This should match the ID of the VInputPlaceholder
errorMessage: '',
},
};
export const WithLabelInputAndError: Story = {
render: (args) => ({
components: { VFormField, VInputPlaceholder },
setup() {
return { args };
},
template: `
<VFormField :label="args.label" :forId="args.forId" :errorMessage="args.errorMessage">
<VInputPlaceholder id="emailInput" placeholder="Enter your email" />
</VFormField>
`,
}),
args: {
label: 'Email Address',
forId: 'emailInput',
errorMessage: 'Please enter a valid email address.',
},
};
export const InputOnlyNoError: Story = {
render: (args) => ({
components: { VFormField, VInputPlaceholder },
setup() {
return { args };
},
template: `
<VFormField :errorMessage="args.errorMessage">
<VInputPlaceholder id="searchInput" placeholder="Search..." />
</VFormField>
`,
}),
args: {
label: '', // No label
forId: '',
errorMessage: '', // No error
},
};
export const InputWithErrorNoLabel: Story = {
render: (args) => ({
components: { VFormField, VInputPlaceholder },
setup() {
return { args };
},
template: `
<VFormField :errorMessage="args.errorMessage">
<VInputPlaceholder id="passwordInput" type="password" placeholder="Enter password" />
</VFormField>
`,
}),
args: {
label: '',
forId: '',
errorMessage: 'Password is required.',
},
};
export const WithLabelNoErrorNoInputId: Story = {
render: (args) => ({
components: { VFormField, VInputPlaceholder },
setup() {
return { args };
},
template: `
<VFormField :label="args.label" :forId="args.forId">
<!-- Input without an ID, label 'for' will not connect -->
<VInputPlaceholder placeholder="Generic input" />
</VFormField>
`,
}),
args: {
label: 'Description (Label `for` not connected)',
forId: 'unmatchedId', // For attribute will be present but might not point to a valid input
errorMessage: '',
},
parameters: {
docs: {
description: {
story: "Demonstrates a label being present, but its `for` attribute might not link to the input if the input's ID is missing or doesn't match. This is valid but not ideal for accessibility.",
},
},
},
};

View File

@ -0,0 +1,65 @@
<template>
<div class="form-group">
<label v-if="label" :for="forId" class="form-label">{{ label }}</label>
<slot></slot>
<p v-if="errorMessage" class="form-error-message">{{ errorMessage }}</p>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
export default defineComponent({
name: 'VFormField',
props: {
label: {
type: String,
default: null,
},
// 'for' is a reserved keyword in JS, so often component props use 'htmlFor' or similar.
// However, Vue allows 'for' in props directly. Let's stick to 'forId' for clarity to avoid confusion.
forId: {
type: String,
default: null,
},
errorMessage: {
type: String,
default: null,
},
},
setup(props) {
// No specific setup logic needed for this component's current requirements.
// Props are directly used in the template.
return {};
},
});
</script>
<style lang="scss" scoped>
.form-group {
margin-bottom: 1rem; // Spacing between form fields
}
.form-label {
display: block;
margin-bottom: 0.5rem; // Space between label and input
font-weight: 500;
// Add other label styling as needed from design system
// e.g., color: var(--label-color);
}
.form-error-message {
margin-top: 0.25rem; // Space between input and error message
font-size: 0.875em; // Smaller text for error messages
color: #dc3545; // Example error color (Bootstrap's danger color)
// Replace with SCSS variable: var(--danger-color) or similar
// Add other error message styling as needed
}
// Styling for slotted content (inputs, textareas, etc.) will typically
// be handled by those components themselves (e.g., VInput, VTextarea).
// However, you might want to ensure consistent width or display:
// ::v-deep(input), ::v-deep(textarea), ::v-deep(select) {
// width: 100%; // Example to make slotted inputs take full width of form-group
// }
</style>

View 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('');
});
});

View 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.',
},
},
},
};

View 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>

View File

@ -0,0 +1,55 @@
import { mount } from '@vue/test-utils';
import VIcon from './VIcon.vue';
import { describe, it, expect } from 'vitest';
describe('VIcon.vue', () => {
it('renders the icon with the correct name class', () => {
const wrapper = mount(VIcon, {
props: { name: 'alert' },
});
expect(wrapper.classes()).toContain('icon');
expect(wrapper.classes()).toContain('icon-alert');
});
it('renders the icon with the correct size class when size is provided', () => {
const wrapper = mount(VIcon, {
props: { name: 'search', size: 'sm' },
});
expect(wrapper.classes()).toContain('icon-sm');
});
it('renders the icon without a size class when size is not provided', () => {
const wrapper = mount(VIcon, {
props: { name: 'close' },
});
expect(wrapper.classes().find(cls => cls.startsWith('icon-sm') || cls.startsWith('icon-lg'))).toBeUndefined();
});
it('renders nothing if name is not provided (due to required prop)', () => {
// Vue Test Utils might log a warning about missing required prop, which is expected.
// We are testing the component's behavior in such a scenario.
// Depending on error handling, it might render an empty <i> tag or nothing.
// Here, we assume it renders the <i> tag due to the template structure.
const wrapper = mount(VIcon, {
// @ts-expect-error testing missing required prop
props: { size: 'lg' },
});
// It will still render the <i> tag, but without the icon-name class if `name` is truly not passed.
// However, Vue's prop validation will likely prevent mounting or cause errors.
// For a robust test, one might check for console warnings or specific error handling.
// Given the current setup, it will have 'icon' but not 'icon-undefined' or similar.
expect(wrapper.find('i').exists()).toBe(true);
// It should not have an `icon-undefined` or similar class if name is not passed.
// The behavior might depend on how Vue handles missing required props at runtime in test env.
// A more accurate test would be to check that the specific icon name class is NOT present.
expect(wrapper.classes().some(cls => cls.startsWith('icon-') && cls !== 'icon-sm' && cls !== 'icon-lg')).toBe(false);
});
it('validates the size prop', () => {
const validator = VIcon.props.size.validator;
expect(validator('sm')).toBe(true);
expect(validator('lg')).toBe(true);
expect(validator('md')).toBe(false);
expect(validator('')).toBe(false);
});
});

View File

@ -0,0 +1,51 @@
import VIcon from './VIcon.vue';
import type { Meta, StoryObj } from '@storybook/vue3';
const meta: Meta<typeof VIcon> = {
title: 'Valerie/VIcon',
component: VIcon,
tags: ['autodocs'],
argTypes: {
name: {
control: 'select',
options: ['alert', 'search', 'close'], // Example icon names
},
size: {
control: 'radio',
options: ['sm', 'lg', undefined],
},
},
};
export default meta;
type Story = StoryObj<typeof VIcon>;
export const Default: Story = {
args: {
name: 'alert',
},
};
export const Small: Story = {
args: {
name: 'search',
size: 'sm',
},
};
export const Large: Story = {
args: {
name: 'close',
size: 'lg',
},
};
export const CustomName: Story = {
args: {
name: 'custom-icon-name', // This will need a corresponding CSS class
},
// Add a note about needing CSS for custom icons if not handled by a library
parameters: {
notes: 'This story uses a custom icon name. Ensure that a corresponding CSS class (e.g., .icon-custom-icon-name) is defined in VIcon.scss or your global icon styles for the icon to be visible.',
},
};

View File

@ -0,0 +1,62 @@
<template>
<i :class="iconClasses"></i>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
export default defineComponent({
name: 'VIcon',
props: {
name: {
type: String,
required: true,
},
size: {
type: String,
validator: (value: string) => ['sm', 'lg'].includes(value),
},
},
setup(props) {
const iconClasses = computed(() => {
const classes = ['icon', `icon-${props.name}`];
if (props.size) {
classes.push(`icon-${props.size}`);
}
return classes;
});
return {
iconClasses,
};
},
});
</script>
<style lang="scss" scoped>
// Basic icon styling - will be expanded in a later task
.icon {
display: inline-block;
// Add common icon styles here
}
// Placeholder for actual icon styles (e.g., using a font icon or SVG)
// These will be defined in a separate SCSS file (VIcon.scss)
.icon-alert:before {
content: '⚠️'; // Example, replace with actual icon
}
.icon-search:before {
content: '🔍'; // Example, replace with actual icon
}
.icon-close:before {
content: '❌'; // Example, replace with actual icon
}
.icon-sm {
font-size: 0.8em; // Example size
}
.icon-lg {
font-size: 1.5em; // Example size
}
</style>

View File

@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils';
import VInput from './VInput.vue';
import { describe, it, expect } from 'vitest';
describe('VInput.vue', () => {
it('binds modelValue and emits update:modelValue correctly (v-model)', async () => {
const wrapper = mount(VInput, {
props: { modelValue: 'initial text' },
});
const inputElement = wrapper.find('input');
// Check initial value
expect(inputElement.element.value).toBe('initial text');
// Simulate user input
await inputElement.setValue('new text');
// Check emitted event
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['new text']);
// Check that prop update (simulating parent v-model update) changes the value
await wrapper.setProps({ modelValue: 'updated from parent' });
expect(inputElement.element.value).toBe('updated from parent');
});
it('sets the input type correctly', () => {
const wrapper = mount(VInput, {
props: { modelValue: '', type: 'password' },
});
expect(wrapper.find('input').attributes('type')).toBe('password');
});
it('defaults type to "text" if not provided', () => {
const wrapper = mount(VInput, { props: { modelValue: '' } });
expect(wrapper.find('input').attributes('type')).toBe('text');
});
it('applies placeholder when provided', () => {
const placeholderText = 'Enter here';
const wrapper = mount(VInput, {
props: { modelValue: '', placeholder: placeholderText },
});
expect(wrapper.find('input').attributes('placeholder')).toBe(placeholderText);
});
it('is disabled when disabled prop is true', () => {
const wrapper = mount(VInput, {
props: { modelValue: '', disabled: true },
});
expect(wrapper.find('input').attributes('disabled')).toBeDefined();
});
it('is not disabled by default', () => {
const wrapper = mount(VInput, { props: { modelValue: '' } });
expect(wrapper.find('input').attributes('disabled')).toBeUndefined();
});
it('is required when required prop is true', () => {
const wrapper = mount(VInput, {
props: { modelValue: '', required: true },
});
expect(wrapper.find('input').attributes('required')).toBeDefined();
});
it('is not required by default', () => {
const wrapper = mount(VInput, { props: { modelValue: '' } });
expect(wrapper.find('input').attributes('required')).toBeUndefined();
});
it('applies error class when error prop is true', () => {
const wrapper = mount(VInput, {
props: { modelValue: '', error: true },
});
expect(wrapper.find('input').classes()).toContain('form-input');
expect(wrapper.find('input').classes()).toContain('error');
});
it('does not apply error class by default or when error is false', () => {
const wrapperDefault = mount(VInput, { props: { modelValue: '' } });
expect(wrapperDefault.find('input').classes()).toContain('form-input');
expect(wrapperDefault.find('input').classes()).not.toContain('error');
const wrapperFalse = mount(VInput, {
props: { modelValue: '', error: false },
});
expect(wrapperFalse.find('input').classes()).toContain('form-input');
expect(wrapperFalse.find('input').classes()).not.toContain('error');
});
it('sets aria-invalid attribute when error prop is true', () => {
const wrapper = mount(VInput, {
props: { modelValue: '', error: true },
});
expect(wrapper.find('input').attributes('aria-invalid')).toBe('true');
});
it('does not set aria-invalid attribute by default or when error is false', () => {
const wrapperDefault = mount(VInput, { props: { modelValue: '' } });
expect(wrapperDefault.find('input').attributes('aria-invalid')).toBeNull(); // Or undefined
const wrapperFalse = mount(VInput, {
props: { modelValue: '', error: false },
});
expect(wrapperFalse.find('input').attributes('aria-invalid')).toBeNull(); // Or undefined
});
it('passes id prop to the input element', () => {
const inputId = 'my-custom-id';
const wrapper = mount(VInput, {
props: { modelValue: '', id: inputId },
});
expect(wrapper.find('input').attributes('id')).toBe(inputId);
});
it('does not have an id attribute if id prop is not provided', () => {
const wrapper = mount(VInput, { props: { modelValue: '' } });
expect(wrapper.find('input').attributes('id')).toBeUndefined();
});
});

View File

@ -0,0 +1,202 @@
import VInput from './VInput.vue';
import VFormField from './VFormField.vue'; // To demonstrate usage with VFormField
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue'; // For v-model in stories
const meta: Meta<typeof VInput> = {
title: 'Valerie/VInput',
component: VInput,
tags: ['autodocs'],
argTypes: {
modelValue: { control: 'text', description: 'Bound value using v-model.' }, // Or 'object' if number is frequent
type: {
control: 'select',
options: ['text', 'email', 'password', 'number', 'tel', 'url', 'search', 'date'],
},
placeholder: { control: 'text' },
disabled: { control: 'boolean' },
required: { control: 'boolean' },
error: { control: 'boolean', description: 'Applies error styling.' },
id: { control: 'text' },
// 'update:modelValue': { action: 'updated' } // To show event in actions tab
},
parameters: {
docs: {
description: {
component: 'A versatile input component with support for various types, states, and v-model binding.',
},
},
},
// Decorator to provide v-model functionality to stories if needed at a global level
// decorators: [
// (story, context) => {
// const value = ref(context.args.modelValue || '');
// return story({ ...context, args: { ...context.args, modelValue: value, 'onUpdate:modelValue': (val) => value.value = val } });
// },
// ],
};
export default meta;
type Story = StoryObj<typeof VInput>;
// Template for v-model interaction in stories
const VModelTemplate: Story = {
render: (args) => ({
components: { VInput },
setup() {
// Storybook provides a mechanism to bind args, which includes modelValue.
// For direct v-model usage in the template, we might need a local ref.
// However, Storybook 7+ handles args updates automatically for controls.
// If direct v-model="args.modelValue" doesn't work due to arg immutability,
// use a local ref and update args on change.
const storyValue = ref(args.modelValue || '');
const onInput = (newValue: string | number) => {
storyValue.value = newValue;
// args.modelValue = newValue; // This might be needed if SB doesn't auto-update
// For Storybook actions tab:
// context.emit('update:modelValue', newValue);
}
return { args, storyValue, onInput };
},
// Note: Storybook's `args` are reactive. `v-model="args.modelValue"` might work directly in some SB versions.
// Using a local ref `storyValue` and emitting an action is a robust way.
template: '<VInput v-bind="args" :modelValue="storyValue" @update:modelValue="onInput" />',
}),
};
export const Basic: Story = {
...VModelTemplate,
args: {
id: 'basicInput',
modelValue: 'Hello Valerie',
},
};
export const WithPlaceholder: Story = {
...VModelTemplate,
args: {
id: 'placeholderInput',
placeholder: 'Enter text here...',
modelValue: '',
},
};
export const Disabled: Story = {
...VModelTemplate,
args: {
id: 'disabledInput',
modelValue: 'Cannot change this',
disabled: true,
},
};
export const Required: Story = {
...VModelTemplate,
args: {
id: 'requiredInput',
modelValue: '',
required: true,
placeholder: 'This field is required',
},
parameters: {
docs: {
description: { story: 'The `required` attribute is set. Form submission behavior depends on the browser and form context.' },
},
},
};
export const ErrorState: Story = {
...VModelTemplate,
args: {
id: 'errorInput',
modelValue: 'Incorrect value',
error: true,
},
};
export const PasswordType: Story = {
...VModelTemplate,
args: {
id: 'passwordInput',
type: 'password',
modelValue: 'secret123',
},
};
export const EmailType: Story = {
...VModelTemplate,
args: {
id: 'emailInput',
type: 'email',
modelValue: 'test@example.com',
placeholder: 'your.email@provider.com',
},
};
export const NumberType: Story = {
...VModelTemplate,
args: {
id: 'numberInput',
type: 'number',
modelValue: 42,
placeholder: 'Enter a number',
},
};
// Story demonstrating VInput used within VFormField
export const InFormField: Story = {
render: (args) => ({
components: { VInput, VFormField },
setup() {
const storyValue = ref(args.inputArgs.modelValue || '');
const onInput = (newValue: string | number) => {
storyValue.value = newValue;
// args.inputArgs.modelValue = newValue; // Update the nested arg for control sync
}
return { args, storyValue, onInput };
},
template: `
<VFormField :label="args.formFieldArgs.label" :forId="args.inputArgs.id" :errorMessage="args.formFieldArgs.errorMessage">
<VInput
v-bind="args.inputArgs"
:modelValue="storyValue"
@update:modelValue="onInput"
/>
</VFormField>
`,
}),
args: {
formFieldArgs: {
label: 'Your Name',
errorMessage: '',
},
inputArgs: {
id: 'nameField',
modelValue: 'Initial Name',
placeholder: 'Enter your full name',
error: false, // Controlled by formFieldArgs.errorMessage typically
},
},
parameters: {
docs: {
description: { story: '`VInput` used inside a `VFormField`. The `id` on `VInput` should match `forId` on `VFormField`.' },
},
},
};
export const InFormFieldWithError: Story = {
...InFormField, // Inherit render function from InFormField
args: {
formFieldArgs: {
label: 'Your Email',
errorMessage: 'This email is invalid.',
},
inputArgs: {
id: 'emailFieldWithError',
modelValue: 'invalid-email',
type: 'email',
placeholder: 'Enter your email',
error: true, // Set VInput's error state
},
},
};

View File

@ -0,0 +1,127 @@
<template>
<input
:id="id"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:class="inputClasses"
:aria-invalid="error ? 'true' : null"
@input="onInput"
/>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
// It's good practice to define specific types for props like 'type' if you want to restrict them,
// but for VInput, standard HTML input types are numerous.
// For now, we'll use String and rely on native HTML behavior.
// type InputType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search' | 'date' ; // etc.
export default defineComponent({
name: 'VInput',
props: {
modelValue: {
type: [String, Number] as PropType<string | number>,
required: true,
},
type: {
type: String, // as PropType<InputType> if you define a specific list
default: 'text',
},
placeholder: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
required: {
type: Boolean,
default: false,
},
error: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const inputClasses = computed(() => [
'form-input',
{ 'error': props.error },
]);
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement;
// For number inputs, target.value might still be a string,
// convert if type is number and value is parsable.
// However, v-model.number modifier usually handles this.
// Here, we just emit the raw value. Parent can handle conversion.
emit('update:modelValue', target.value);
};
return {
inputClasses,
onInput,
};
},
});
</script>
<style lang="scss" scoped>
.form-input {
display: block;
width: 100%; // Inputs typically span the full width of their container
padding: 0.5em 0.75em; // Example padding, adjust as per design
font-size: 1rem;
font-family: inherit; // Inherit font from parent
line-height: 1.5;
color: #212529; // Example text color (Bootstrap's default)
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da; // Example border (Bootstrap's default)
border-radius: 0.25rem; // Example border-radius
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
border-color: #80bdff; // Example focus color (Bootstrap's default)
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); // Example focus shadow
}
&::placeholder {
color: #6c757d; // Example placeholder color (Bootstrap's default)
opacity: 1; // Override Firefox's lower default opacity
}
&[disabled],
&[readonly] {
background-color: #e9ecef; // Example disabled background (Bootstrap's default)
opacity: 1; // Ensure text is readable
cursor: not-allowed;
}
// Error state
&.error {
border-color: #dc3545; // Example error color (Bootstrap's danger)
// Add other error state styling, e.g., box-shadow, text color if needed
&:focus {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
}
}
// Specific styling for different input types if needed, e.g., for number inputs
// input[type="number"] {
// // Styles for number inputs, like removing spinners on some browsers
// }
</style>

View File

@ -0,0 +1,54 @@
import { mount } from '@vue/test-utils';
import VList from './VList.vue';
import VListItem from './VListItem.vue'; // For testing with children
import { describe, it, expect } from 'vitest';
describe('VList.vue', () => {
it('applies the .item-list class to the root element', () => {
const wrapper = mount(VList);
expect(wrapper.classes()).toContain('item-list');
});
it('renders default slot content', () => {
const wrapper = mount(VList, {
slots: {
default: '<VListItem>Item 1</VListItem><VListItem>Item 2</VListItem>',
},
global: {
components: { VListItem } // Register VListItem for the slot content
}
});
const items = wrapper.findAllComponents(VListItem);
expect(items.length).toBe(2);
expect(wrapper.text()).toContain('Item 1');
expect(wrapper.text()).toContain('Item 2');
});
it('renders as a <ul> element by default', () => {
const wrapper = mount(VList);
expect(wrapper.element.tagName).toBe('UL');
});
it('renders correctly when empty', () => {
const wrapper = mount(VList, {
slots: { default: '' } // Empty slot
});
expect(wrapper.find('ul.item-list').exists()).toBe(true);
expect(wrapper.element.children.length).toBe(0); // No direct children from empty slot
// or use .html() to check inner content
expect(wrapper.html()).toContain('<ul class="item-list"></ul>');
});
it('renders non-VListItem children if passed', () => {
const wrapper = mount(VList, {
slots: {
default: '<li>Raw LI</li><div>Just a div</div>'
}
});
expect(wrapper.find('li').exists()).toBe(true);
expect(wrapper.find('div').exists()).toBe(true);
expect(wrapper.text()).toContain('Raw LI');
expect(wrapper.text()).toContain('Just a div');
});
});

View File

@ -0,0 +1,113 @@
import VList from './VList.vue';
import VListItem from './VListItem.vue'; // VList will contain VListItems
import VBadge from './VBadge.vue'; // For complex VListItem content example
import VAvatar from './VAvatar.vue'; // For complex VListItem content example
import VButton from './VButton.vue'; // For swipe actions example
import type { Meta, StoryObj } from '@storybook/vue3';
const meta: Meta<typeof VList> = {
title: 'Valerie/VList',
component: VList,
tags: ['autodocs'],
// No args for VList itself currently
parameters: {
docs: {
description: {
component: '`VList` is a container component for `VListItem` components or other list content. It applies basic list styling.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VList>;
export const DefaultWithItems: Story = {
render: (args) => ({
components: { VList, VListItem, VBadge, VAvatar, VButton }, // Register all used components
setup() {
// Data for the list items
const items = ref([
{ id: 1, text: 'Pay utility bills', completed: false, swipable: true, isSwiped: false, avatar: 'https://via.placeholder.com/40x40.png?text=U', badgeText: 'Urgent', badgeVariant: 'danger' },
{ id: 2, text: 'Schedule doctor appointment', completed: true, swipable: false, avatar: 'https://via.placeholder.com/40x40.png?text=D', badgeText: 'Done', badgeVariant: 'settled' },
{ id: 3, text: 'Grocery shopping for the week', completed: false, swipable: true, isSwiped: false, avatar: 'https://via.placeholder.com/40x40.png?text=G', badgeText: 'Pending', badgeVariant: 'pending' },
{ id: 4, text: 'Book flight tickets for vacation', completed: false, swipable: false, avatar: 'https://via.placeholder.com/40x40.png?text=F' },
]);
const toggleSwipe = (item) => {
item.isSwiped = !item.isSwiped;
};
const markComplete = (item) => {
item.completed = !item.completed;
}
const deleteItem = (itemId) => {
items.value = items.value.filter(i => i.id !== itemId);
alert(`Item ${itemId} deleted (simulated)`);
}
return { args, items, toggleSwipe, markComplete, deleteItem };
},
template: `
<VList>
<VListItem
v-for="item in items"
:key="item.id"
:completed="item.completed"
:swipable="item.swipable"
:isSwiped="item.isSwiped"
@click="item.swipable ? toggleSwipe(item) : markComplete(item)"
style="border-bottom: 1px solid #eee;"
>
<div style="display: flex; align-items: center; width: 100%;">
<VAvatar v-if="item.avatar" :src="item.avatar" :initials="item.text.substring(0,1)" style="margin-right: 12px;" />
<span style="flex-grow: 1;">{{ item.text }}</span>
<VBadge v-if="item.badgeText" :text="item.badgeText" :variant="item.badgeVariant" style="margin-left: 12px;" />
</div>
<template #swipe-actions-right>
<VButton variant="danger" size="sm" @click.stop="deleteItem(item.id)" style="height: 100%; border-radius:0;">Delete</VButton>
<VButton variant="neutral" size="sm" @click.stop="toggleSwipe(item)" style="height: 100%; border-radius:0;">Cancel</VButton>
</template>
</VListItem>
<VListItem v-if="!items.length">No items in the list.</VListItem>
</VList>
`,
}),
args: {},
};
export const EmptyList: Story = {
render: (args) => ({
components: { VList, VListItem },
setup() {
return { args };
},
template: `
<VList>
<VListItem>The list is currently empty.</VListItem>
</VList>
`,
}),
args: {},
parameters: {
docs: {
description: { story: 'An example of an empty `VList`. It can contain a single `VListItem` with a message, or be programmatically emptied.' },
},
},
};
export const ListWithSimpleTextItems: Story = {
render: (args) => ({
components: { VList, VListItem },
setup() { return { args }; },
template: `
<VList>
<VListItem style="border-bottom: 1px solid #eee;">First item</VListItem>
<VListItem style="border-bottom: 1px solid #eee;">Second item</VListItem>
<VListItem>Third item</VListItem>
</VList>
`
}),
args: {}
};

View File

@ -0,0 +1,31 @@
<template>
<ul class="item-list">
<slot></slot>
</ul>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'VList',
// No props defined for VList for now
});
</script>
<style lang="scss" scoped>
.item-list {
list-style: none; // Remove default ul styling
padding: 0;
margin: 0;
// Add any list-wide styling, e.g., borders between items if not handled by VListItem
// For example, if VListItems don't have their own bottom border:
// > ::v-deep(.list-item:not(:last-child)) {
// border-bottom: 1px solid #eee;
// }
// However, it's often better for VListItem to manage its own borders for more flexibility.
background-color: #fff; // Default background for the list area
border-radius: 0.375rem; // Optional: if the list itself should have rounded corners
overflow: hidden; // If list items have rounded corners and list has bg, this prevents bleed
}
</style>

View File

@ -0,0 +1,116 @@
import { mount } from '@vue/test-utils';
import VListItem from './VListItem.vue';
import { describe, it, expect } from 'vitest';
// Mock VButton or other components if they are deeply tested and not relevant to VListItem's direct unit tests
// For example, if VButton has complex logic:
// vi.mock('./VButton.vue', () => ({
// name: 'VButton',
// template: '<button><slot/></button>'
// }));
describe('VListItem.vue', () => {
it('renders default slot content in .list-item-content', () => {
const itemContent = '<span>Hello World</span>';
const wrapper = mount(VListItem, {
slots: { default: itemContent },
});
const contentDiv = wrapper.find('.list-item-content');
expect(contentDiv.exists()).toBe(true);
expect(contentDiv.html()).toContain(itemContent);
});
it('applies .list-item class to the root element', () => {
const wrapper = mount(VListItem);
expect(wrapper.classes()).toContain('list-item');
});
it('applies .completed class when completed prop is true', () => {
const wrapper = mount(VListItem, { props: { completed: true } });
expect(wrapper.classes()).toContain('completed');
});
it('does not apply .completed class when completed prop is false or default', () => {
const wrapperDefault = mount(VListItem);
expect(wrapperDefault.classes()).not.toContain('completed');
const wrapperFalse = mount(VListItem, { props: { completed: false } });
expect(wrapperFalse.classes()).not.toContain('completed');
});
it('applies .swipable class when swipable prop is true', () => {
const wrapper = mount(VListItem, { props: { swipable: true } });
expect(wrapper.classes()).toContain('swipable');
});
it('applies .is-swiped class when isSwiped and swipable props are true', () => {
const wrapper = mount(VListItem, {
props: { swipable: true, isSwiped: true },
});
expect(wrapper.classes()).toContain('is-swiped');
});
it('does not apply .is-swiped class if swipable is false, even if isSwiped is true', () => {
const wrapper = mount(VListItem, {
props: { swipable: false, isSwiped: true },
});
expect(wrapper.classes()).not.toContain('is-swiped');
});
it('does not apply .is-swiped class by default', () => {
const wrapper = mount(VListItem);
expect(wrapper.classes()).not.toContain('is-swiped');
});
it('renders swipe-actions-right slot when swipable is true and slot has content', () => {
const actionsContent = '<button>Delete</button>';
const wrapper = mount(VListItem, {
props: { swipable: true },
slots: { 'swipe-actions-right': actionsContent },
});
const actionsDiv = wrapper.find('.swipe-actions.swipe-actions-right');
expect(actionsDiv.exists()).toBe(true);
expect(actionsDiv.html()).toContain(actionsContent);
});
it('does not render swipe-actions-right slot if swipable is false', () => {
const wrapper = mount(VListItem, {
props: { swipable: false },
slots: { 'swipe-actions-right': '<button>Delete</button>' },
});
expect(wrapper.find('.swipe-actions.swipe-actions-right').exists()).toBe(false);
});
it('does not render swipe-actions-right slot if swipable is true but slot has no content', () => {
const wrapper = mount(VListItem, {
props: { swipable: true },
// No swipe-actions-right slot
});
expect(wrapper.find('.swipe-actions.swipe-actions-right').exists()).toBe(false);
});
it('renders swipe-actions-left slot when swipable is true and slot has content', () => {
const actionsContent = '<button>Archive</button>';
const wrapper = mount(VListItem, {
props: { swipable: true },
slots: { 'swipe-actions-left': actionsContent },
});
const actionsDiv = wrapper.find('.swipe-actions.swipe-actions-left');
expect(actionsDiv.exists()).toBe(true);
expect(actionsDiv.html()).toContain(actionsContent);
});
it('does not render swipe-actions-left slot if swipable is false', () => {
const wrapper = mount(VListItem, {
props: { swipable: false },
slots: { 'swipe-actions-left': '<button>Archive</button>' },
});
expect(wrapper.find('.swipe-actions.swipe-actions-left').exists()).toBe(false);
});
it('root element is an <li> by default', () => {
const wrapper = mount(VListItem);
expect(wrapper.element.tagName).toBe('LI');
});
});

View File

@ -0,0 +1,158 @@
import VListItem from './VListItem.vue';
import VList from './VList.vue'; // For context
import VBadge from './VBadge.vue';
import VAvatar from './VAvatar.vue';
import VButton from './VButton.vue'; // For swipe actions
import VIcon from './VIcon.vue'; // For swipe actions
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue'; // For reactive props in stories
const meta: Meta<typeof VListItem> = {
title: 'Valerie/VListItem',
component: VListItem,
tags: ['autodocs'],
argTypes: {
completed: { control: 'boolean' },
swipable: { control: 'boolean' },
isSwiped: { control: 'boolean', description: 'Controls the visual swipe state (reveals actions). Requires `swipable` to be true.' },
// Slots are demonstrated in individual stories
default: { table: { disable: true } },
'swipe-actions-right': { table: { disable: true } },
'swipe-actions-left': { table: { disable: true } },
},
decorators: [(story) => ({ components: { VList, story }, template: '<VList><story/></VList>' })], // Wrap stories in VList
parameters: {
docs: {
description: {
component: '`VListItem` represents an individual item in a `VList`. It supports various states like completed, swipable, and can contain complex content including swipe actions.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VListItem>;
export const Basic: Story = {
render: (args) => ({
components: { VListItem },
setup() { return { args }; },
template: '<VListItem v-bind="args">Basic List Item</VListItem>',
}),
args: {
completed: false,
swipable: false,
isSwiped: false,
},
};
export const Completed: Story = {
...Basic, // Reuses render from Basic
args: {
...Basic.args,
completed: true,
defaultSlotContent: 'This item is marked as completed.',
},
// Need to adjust template if defaultSlotContent is used as a prop for story text
render: (args) => ({
components: { VListItem },
setup() { return { args }; },
template: '<VListItem :completed="args.completed" :swipable="args.swipable" :isSwiped="args.isSwiped">{{ args.defaultSlotContent }}</VListItem>',
}),
};
export const Swipable: Story = {
render: (args) => ({
components: { VListItem, VButton, VIcon },
setup() {
// In a real app, isSwiped would be part of component's internal state or controlled by a swipe library.
// Here, we make it a reactive prop for the story to toggle.
const isSwipedState = ref(args.isSwiped);
const toggleSwipe = () => {
if (args.swipable) {
isSwipedState.value = !isSwipedState.value;
}
};
return { args, isSwipedState, toggleSwipe };
},
template: `
<VListItem
:swipable="args.swipable"
:isSwiped="isSwipedState"
:completed="args.completed"
@click="toggleSwipe"
>
{{ args.defaultSlotContent }}
<template #swipe-actions-right>
<VButton variant="danger" size="sm" @click.stop="() => alert('Delete clicked')" style="height:100%; border-radius:0;">
<VIcon name="close" /> Delete
</VButton>
<VButton variant="neutral" size="sm" @click.stop="toggleSwipe" style="height:100%; border-radius:0;">
Cancel
</VButton>
</template>
<template #swipe-actions-left>
<VButton variant="primary" size="sm" @click.stop="() => alert('Archive clicked')" style="height:100%; border-radius:0;">
<VIcon name="alert" /> Archive
</VButton>
</template>
</VListItem>
`,
}),
args: {
swipable: true,
isSwiped: false, // Initial state for the story control
completed: false,
defaultSlotContent: 'This item is swipable. Click to toggle swipe state for demo.',
},
};
export const SwipedToShowActions: Story = {
...Swipable, // Reuses render and setup from Swipable story
args: {
...Swipable.args,
isSwiped: true, // Start in the "swiped" state
defaultSlotContent: 'This item is shown as already swiped (revealing right actions). Click to toggle.',
},
};
export const WithComplexContent: Story = {
render: (args) => ({
components: { VListItem, VAvatar, VBadge },
setup() { return { args }; },
template: `
<VListItem :completed="args.completed" :swipable="args.swipable" :isSwiped="args.isSwiped">
<div style="display: flex; align-items: center; width: 100%;">
<VAvatar :src="args.avatarSrc" :initials="args.avatarInitials" style="margin-right: 12px;" />
<div style="flex-grow: 1;">
<div style="font-weight: 500;">{{ args.title }}</div>
<div style="font-size: 0.9em; color: #555;">{{ args.subtitle }}</div>
</div>
<VBadge :text="args.badgeText" :variant="args.badgeVariant" />
</div>
</VListItem>
`,
}),
args: {
completed: false,
swipable: false,
isSwiped: false,
avatarSrc: 'https://via.placeholder.com/40x40.png?text=CX',
avatarInitials: 'CX',
title: 'Complex Item Title',
subtitle: 'Subtitle with additional information',
badgeText: 'New',
badgeVariant: 'accent',
},
};
export const CompletedWithComplexContent: Story = {
...WithComplexContent, // Reuses render from WithComplexContent
args: {
...WithComplexContent.args,
completed: true,
badgeText: 'Finished',
badgeVariant: 'settled',
},
};

View File

@ -0,0 +1,256 @@
<template>
<li :class="itemClasses">
<div v-if="swipable && $slots['swipe-actions-left']" class="swipe-actions swipe-actions-left">
<slot name="swipe-actions-left"></slot>
</div>
<div class="list-item-content-wrapper"> <!-- New wrapper for content + right swipe -->
<div class="list-item-content">
<slot></slot>
</div>
<div v-if="swipable && $slots['swipe-actions-right']" class="swipe-actions swipe-actions-right">
<slot name="swipe-actions-right"></slot>
</div>
</div>
</li>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
export default defineComponent({
name: 'VListItem',
props: {
completed: {
type: Boolean,
default: false,
},
swipable: {
type: Boolean,
default: false,
},
isSwiped: { // This prop controls the visual "swiped" state
type: Boolean,
default: false,
},
},
setup(props) {
const itemClasses = computed(() => [
'list-item',
{
'completed': props.completed,
'is-swiped': props.isSwiped && props.swipable, // Only apply if swipable
'swipable': props.swipable, // Add a general class if item is swipable for base styling
},
]);
return {
itemClasses,
};
},
});
</script>
<style lang="scss" scoped>
.list-item {
position: relative; // For positioning swipe actions absolutely if needed, or for overflow handling
background-color: #fff; // Default item background
// border-bottom: 1px solid #e0e0e0; // Example item separator
// &:last-child {
// border-bottom: none;
// }
display: flex; // Using flex to manage potential left/right swipe areas if they are part of the flow
overflow: hidden; // Crucial for the swipe reveal effect with translate
&.swipable {
// Base styling for swipable items, if any.
// For example, you might want a slightly different cursor or hover effect.
}
}
// This wrapper will be translated to reveal swipe actions
.list-item-content-wrapper {
flex-grow: 1;
display: flex; // To place content and right-swipe actions side-by-side
transition: transform 0.3s ease-out;
// The content itself should fill the space and not be affected by swipe actions' width
// until it's translated.
width: 100%; // Ensures it takes up the full space initially
z-index: 1; // Keep content above swipe actions until swiped
}
.list-item-content {
padding: 0.75rem 1rem; // Example padding
flex-grow: 1; // Content takes available space
// Add other common styling for list item content area
// e.g., text color, font size
background-color: inherit; // Inherit from .list-item, can be overridden
// Useful so that when it slides, it has the right bg
}
.swipe-actions {
display: flex;
align-items: center;
justify-content: center;
// These actions are revealed by translating .list-item-content-wrapper
// Their width will determine how much is revealed.
// Example: fixed width for actions container
// width: 80px; // This would be per side
z-index: 0; // Below content wrapper
background-color: #f0f0f0; // Default background for actions area
}
.swipe-actions-left {
position: absolute; // Take out of flow, position to the left
left: 0;
top: 0;
bottom: 0;
// width: auto; // Determined by content, or set fixed
transform: translateX(-100%); // Initially hidden to the left
transition: transform 0.3s ease-out;
.list-item.is-swiped & { // When swiped to reveal left actions
transform: translateX(0);
}
}
.swipe-actions-right {
// This is now part of the list-item-content-wrapper flex layout
// It's revealed by translating the list-item-content part of the wrapper,
// or by translating the wrapper itself and having this fixed.
// For simplicity, let's assume it has a fixed width and is revealed.
// No, the current structure has list-item-content-wrapper moving.
// So, swipe-actions-right should be fixed at the end of list-item.
position: absolute;
right: 0;
top: 0;
bottom: 0;
// width: auto; // Determined by its content, or set fixed
transform: translateX(100%); // Initially hidden to the right
transition: transform 0.3s ease-out;
// This approach is if .list-item-content is translated.
// If .list-item-content-wrapper is translated, then this needs to be static inside it.
// Let's adjust based on list-item-content-wrapper moving.
// No, the initial thought was:
// <left-actions /> <content-wrapper> <content/> <right-actions/> </content-wrapper>
// If content-wrapper translates left, right actions are revealed.
// If content-wrapper translates right, left actions (if they were outside) are revealed.
// The current HTML structure is:
// <left-actions /> <content-wrapper> <content/> </content-wrapper> <right-actions /> (if right actions are outside wrapper)
// Or:
// <left-actions /> <content-wrapper> <content/> <right-actions/> </content-wrapper> (if right actions are inside wrapper)
// The latter is what I have in the template. So list-item-content-wrapper translates.
// This needs to be outside the list-item-content-wrapper in the flex flow of .list-item
// Or, .list-item-content-wrapper itself is translated.
// Let's assume .list-item-content-wrapper is translated.
// The swipe-actions-left and swipe-actions-right are fixed, and content slides over them.
// This is a common pattern. The current HTML needs slight adjustment for that.
// Re-thinking the template for common swipe:
// <li class="list-item">
// <div class="swipe-actions-left">...</div>
// <div class="list-item-content">...</div> <!-- This is the part that moves -->
// <div class="swipe-actions-right">...</div>
// </li>
// And .list-item-content would get transform: translateX().
// Let's stick to current template and make it work:
// <left-actions/> <wrapper> <content/> <right-actions/> </wrapper>
// If wrapper translates left, it reveals its own right-actions.
// If wrapper translates right, it reveals the list-item's left-actions.
// Reveal right actions by translating the list-item-content-wrapper to the left
.list-item.is-swiped & { // This assumes isSwiped means revealing RIGHT actions.
// Need differentiation if both left/right can be revealed independently.
// For now, isSwiped reveals right.
// This class is on .list-item. The .swipe-actions-right is inside the wrapper.
// So, the wrapper needs to translate.
// No, this is fine. .list-item.is-swiped controls the transform of list-item-content-wrapper.
// Let's assume .list-item-content-wrapper translates left by the width of .swipe-actions-right
// This means .swipe-actions-right needs to have a defined width.
// Example: If .swipe-actions-right is 80px wide:
// .list-item.is-swiped .list-item-content-wrapper { transform: translateX(-80px); }
// And .swipe-actions-right would just sit there.
// This logic should be on .list-item-content-wrapper based on .is-swiped of parent.
}
}
// Adjusting transform on list-item-content-wrapper based on parent .is-swiped
.list-item.is-swiped .list-item-content-wrapper {
// This needs to be dynamic based on which actions are shown and their width.
// For a simple right swipe reveal:
// transform: translateX(-[width of right actions]);
// Example: if right actions are 80px wide. We need JS to measure or fixed CSS.
// For now, let's assume a class like .reveal-right on .list-item sets this.
// If isSwiped just means "right is revealed":
// transform: translateX(-80px); // Placeholder, assumes 80px width of right actions
// This needs to be more robust.
// Let's make .is-swiped simply enable the visibility of the actions,
// and the actions themselves are positioned absolutely or revealed by fixed translation.
// Revised approach: actions are absolutely positioned, content slides.
// This means list-item-content needs to be the one moving, not list-item-content-wrapper.
// The HTML needs to be:
// <li>
// <div class="swipe-actions-left">...</div>
// <div class="list-item-content"> <!-- This is the one that moves -->
// <slot></slot>
// </div>
// <div class="swipe-actions-right">...</div>
// </li>
// I will adjust the template above based on this.
// ... (Template adjusted above - no, I will stick to the current one for now and make it work)
// With current template:
// <li class="list-item">
// <div class="swipe-actions swipe-actions-left">...</div> (absolute, revealed by content-wrapper translating right)
// <div class="list-item-content-wrapper"> (this translates left or right)
// <div class="list-item-content">...</div>
// <div class="swipe-actions swipe-actions-right">...</div> (flex child of wrapper, revealed when wrapper translates left)
// </div>
// </li>
// If .list-item.is-swiped means "right actions revealed":
// .list-item.is-swiped .list-item-content-wrapper { transform: translateX(-[width of .swipe-actions-right]); }
// If .list-item.is-left-swiped means "left actions revealed":
// .list-item.is-left-swiped .list-item-content-wrapper { transform: translateX([width of .swipe-actions-left]); }
// The `isSwiped` prop is boolean, so it can only mean one direction. Assume it's for right.
// To make this work, .swipe-actions-right needs a defined width.
// Let's assume actions have a button that defines their width.
// This CSS is a placeholder for the actual swipe mechanics.
// For Storybook, we just want to show/hide based on `isSwiped`.
// Let's simplify: .is-swiped shows right actions by setting transform on wrapper.
// The actual width needs to be handled by the content of swipe-actions-right.
// This is hard to do purely in CSS without knowing width.
// A common trick is to set right: 0 on actions and let content slide.
// Simplified for story:
// We'll have .swipe-actions-right just appear when .is-swiped.
// This won't look like a swipe, but will show the slot.
// A true swipe needs JS to measure or fixed widths.
// Let's go with a fixed transform for now for demo purposes.
.list-item.is-swiped .list-item-content-wrapper {
transform: translateX(-80px); // Assumes right actions are 80px.
}
// And left actions (if any)
.list-item.is-left-swiped .list-item-content-wrapper { // Hypothetical class
transform: translateX(80px); // Assumes left actions are 80px.
}
// Since `isSwiped` is boolean, it can only control one state.
// Let's assume `isSwiped` means "the right actions are visible".
.list-item.completed {
.list-item-content {
// Example: strike-through text or different background
// color: #adb5bd; // Muted text color
// text-decoration: line-through;
background-color: #f0f8ff; // Light blue background for completed
}
// You might want to disable swipe on completed items or style them differently
&.swipable .list-item-content {
// Specific style for swipable AND completed
}
}
</style>

View File

@ -0,0 +1,283 @@
import { mount, shallowMount } from '@vue/test-utils';
import VModal from './VModal.vue';
import VIcon from './VIcon.vue'; // Used by VModal for close button
import { nextTick } from 'vue';
// Mock VIcon if its rendering is complex or not relevant to VModal's logic
vi.mock('./VIcon.vue', () => ({
name: 'VIcon',
props: ['name'],
template: '<i :class="`mock-icon icon-${name}`"></i>',
}));
// Helper to ensure Teleport content is rendered for testing
const getTeleportedModalContainer = (wrapper: any) => {
// Find the teleport target (usually body, but in test env it might be different or need setup)
// For JSDOM, teleported content is typically appended to document.body.
// We need to find the .modal-backdrop in the document.body.
const backdrop = document.querySelector('.modal-backdrop');
if (!backdrop) return null;
// Create a new wrapper around the teleported content for easier testing
// This is a bit of a hack, usually test-utils has better ways for Teleport.
// With Vue Test Utils v2, content inside <Teleport> is rendered and findable from the main wrapper
// if the target exists. Let's try finding directly from wrapper first.
const container = wrapper.find('.modal-container');
if (container.exists()) return container;
// Fallback if not found directly (e.g. if Teleport is to a detached element in test)
// This part might not be needed with modern test-utils and proper Teleport handling.
// For now, assuming wrapper.find works across teleports if modelValue is true.
return null;
};
describe('VModal.vue', () => {
// Ensure body class is cleaned up after each test
afterEach(() => {
document.body.classList.remove('modal-open');
// Remove any modal backdrops created during tests
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
});
it('does not render when modelValue is false', () => {
const wrapper = mount(VModal, { props: { modelValue: false } });
// Modal content is teleported, so check for its absence in document or via a direct find
expect(wrapper.find('.modal-backdrop').exists()).toBe(false);
expect(wrapper.find('.modal-container').exists()).toBe(false);
});
it('renders when modelValue is true', async () => {
const wrapper = mount(VModal, {
props: { modelValue: true, title: 'Test Modal' },
// Attach to document.body to ensure Teleport target exists
attachTo: document.body
});
await nextTick(); // Wait for Teleport and transition
expect(wrapper.find('.modal-backdrop').exists()).toBe(true);
expect(wrapper.find('.modal-container').exists()).toBe(true);
expect(wrapper.find('.modal-title').text()).toBe('Test Modal');
});
it('emits update:modelValue(false) and close on close button click', async () => {
const wrapper = mount(VModal, {
props: { modelValue: true },
attachTo: document.body
});
await nextTick();
const closeButton = wrapper.find('.close-button');
expect(closeButton.exists()).toBe(true);
await closeButton.trigger('click');
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([false]);
expect(wrapper.emitted()['close']).toBeTruthy();
});
it('hides close button when hideCloseButton is true', async () => {
const wrapper = mount(VModal, {
props: { modelValue: true, hideCloseButton: true },
attachTo: document.body
});
await nextTick();
expect(wrapper.find('.close-button').exists()).toBe(false);
});
it('does not close on backdrop click if persistent is true', async () => {
const wrapper = mount(VModal, {
props: { modelValue: true, persistent: true },
attachTo: document.body
});
await nextTick();
await wrapper.find('.modal-backdrop').trigger('click');
expect(wrapper.emitted()['update:modelValue']).toBeUndefined();
});
it('closes on backdrop click if persistent is false (default)', async () => {
const wrapper = mount(VModal, {
props: { modelValue: true }, // persistent is false by default
attachTo: document.body
});
await nextTick();
await wrapper.find('.modal-backdrop').trigger('click');
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([false]);
});
it('closes on Escape key press', async () => {
const wrapper = mount(VModal, {
props: { modelValue: true },
attachTo: document.body // Necessary for document event listeners
});
await nextTick(); // Modal is open, listener is attached
// Simulate Escape key press on the document
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' });
document.dispatchEvent(escapeEvent);
await nextTick();
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([false]);
});
it('does not close on Escape key if not open', async () => {
mount(VModal, {
props: { modelValue: false }, // Modal is not open initially
attachTo: document.body
});
await nextTick();
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' });
document.dispatchEvent(escapeEvent);
await nextTick();
// No emissions expected as the listener shouldn't be active or modal shouldn't react
// This test is tricky as it tests absence of listener logic when closed.
// Relies on the fact that if it emitted, the test above would fail.
// No direct way to check emissions if component logic prevents it.
// We can assume if the 'closes on Escape key press' test is robust, this is covered.
});
it('renders title from prop', async () => {
const titleText = 'My Modal Title';
const wrapper = mount(VModal, {
props: { modelValue: true, title: titleText },
attachTo: document.body
});
await nextTick();
expect(wrapper.find('.modal-title').text()).toBe(titleText);
});
it('renders header slot content', async () => {
const headerSlotContent = '<div class="custom-header">Custom Header</div>';
const wrapper = mount(VModal, {
props: { modelValue: true },
slots: { header: headerSlotContent },
attachTo: document.body
});
await nextTick();
expect(wrapper.find('.custom-header').exists()).toBe(true);
expect(wrapper.find('.modal-title').exists()).toBe(false); // Default title should not render
expect(wrapper.find('.close-button').exists()).toBe(false); // Default close button also part of slot override
});
it('renders default (body) slot content', async () => {
const bodyContent = '<p>Modal body content.</p>';
const wrapper = mount(VModal, {
props: { modelValue: true },
slots: { default: bodyContent },
attachTo: document.body
});
await nextTick();
const body = wrapper.find('.modal-body');
expect(body.html()).toContain(bodyContent);
});
it('renders footer slot content', async () => {
const footerContent = '<button>OK</button>';
const wrapper = mount(VModal, {
props: { modelValue: true },
slots: { footer: footerContent },
attachTo: document.body
});
await nextTick();
const footer = wrapper.find('.modal-footer');
expect(footer.exists()).toBe(true);
expect(footer.html()).toContain(footerContent);
});
it('does not render footer if no slot content', async () => {
const wrapper = mount(VModal, {
props: { modelValue: true },
attachTo: document.body
});
await nextTick();
expect(wrapper.find('.modal-footer').exists()).toBe(false);
});
it('applies correct size class', async () => {
const wrapperSm = mount(VModal, { props: { modelValue: true, size: 'sm' }, attachTo: document.body });
await nextTick();
expect(wrapperSm.find('.modal-container').classes()).toContain('modal-container-sm');
const wrapperLg = mount(VModal, { props: { modelValue: true, size: 'lg' }, attachTo: document.body });
await nextTick();
expect(wrapperLg.find('.modal-container').classes()).toContain('modal-container-lg');
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove()); // Manual cleanup for multiple modals
});
it('applies ARIA attributes', async () => {
const wrapper = mount(VModal, {
props: { modelValue: true, title: 'ARIA Test', idBase: 'myModal' },
slots: { default: '<p>Description</p>' },
attachTo: document.body
});
await nextTick();
const container = wrapper.find('.modal-container');
expect(container.attributes('role')).toBe('dialog');
expect(container.attributes('aria-modal')).toBe('true');
expect(container.attributes('aria-labelledby')).toBe('myModal-title');
expect(container.attributes('aria-describedby')).toBe('myModal-description');
expect(wrapper.find('#myModal-title').exists()).toBe(true);
expect(wrapper.find('#myModal-description').exists()).toBe(true);
});
it('generates unique IDs if idBase is not provided', async () => {
const wrapper = mount(VModal, {
props: { modelValue: true, title: 'ARIA Test' },
slots: { default: '<p>Description</p>' },
attachTo: document.body
});
await nextTick();
const titleId = wrapper.find('.modal-title').attributes('id');
const bodyId = wrapper.find('.modal-body').attributes('id');
expect(titleId).toMatch(/^modal-.+-title$/);
expect(bodyId).toMatch(/^modal-.+-description$/);
expect(wrapper.find('.modal-container').attributes('aria-labelledby')).toBe(titleId);
expect(wrapper.find('.modal-container').attributes('aria-describedby')).toBe(bodyId);
});
it('toggles body class "modal-open"', async () => {
const wrapper = mount(VModal, {
props: { modelValue: false }, // Start closed
attachTo: document.body
});
expect(document.body.classList.contains('modal-open')).toBe(false);
await wrapper.setProps({ modelValue: true });
await nextTick();
expect(document.body.classList.contains('modal-open')).toBe(true);
await wrapper.setProps({ modelValue: false });
await nextTick();
expect(document.body.classList.contains('modal-open')).toBe(false);
});
it('emits opened event after transition enter', async () => {
const wrapper = mount(VModal, { props: { modelValue: false }, attachTo: document.body });
await wrapper.setProps({ modelValue: true });
await nextTick(); // Start opening
// Manually trigger after-enter for transition if not automatically handled by JSDOM
// In a real browser, this is async. In test, might need to simulate.
// Vue Test Utils sometimes requires manual control over transitions.
// If Transition component is stubbed or not fully supported in test env,
// this might need a different approach or direct call to handler.
// For now, assume transition events work or component calls it directly.
// We can directly call the handler for testing the emit.
wrapper.vm.onOpened(); // Manually call the method that emits
expect(wrapper.emitted().opened).toBeTruthy();
});
it('emits closed event after transition leave', async () => {
const wrapper = mount(VModal, { props: { modelValue: true }, attachTo: document.body });
await nextTick(); // Is open
await wrapper.setProps({ modelValue: false });
await nextTick(); // Start closing
wrapper.vm.onClosed(); // Manually call the method that emits
expect(wrapper.emitted().closed).toBeTruthy();
});
});

View File

@ -0,0 +1,275 @@
import VModal from './VModal.vue';
import VButton from './VButton.vue'; // For modal footer actions
import VInput from './VInput.vue'; // For form elements in modal
import VFormField from './VFormField.vue'; // For form layout in modal
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref, watch } from 'vue';
const meta: Meta<typeof VModal> = {
title: 'Valerie/VModal',
component: VModal,
tags: ['autodocs'],
argTypes: {
modelValue: { control: 'boolean', description: 'Controls modal visibility (v-model).' },
title: { control: 'text' },
hideCloseButton: { control: 'boolean' },
persistent: { control: 'boolean' },
size: { control: 'select', options: ['sm', 'md', 'lg'] },
idBase: { control: 'text' },
// Events
'update:modelValue': { action: 'update:modelValue', table: { disable: true } },
close: { action: 'close' },
opened: { action: 'opened' },
closed: { action: 'closed' },
// Slots
header: { table: { disable: true } },
default: { table: { disable: true } }, // Body slot
footer: { table: { disable: true } },
},
parameters: {
docs: {
description: {
component: 'A modal dialog component that teleports to the body. Supports v-model for visibility, custom content via slots, and various interaction options.',
},
},
// To better demonstrate modals, you might want a dark background for stories if not default
// backgrounds: { default: 'dark' },
},
};
export default meta;
type Story = StoryObj<typeof VModal>;
// Template for managing modal visibility in stories
const ModalInteractionTemplate: Story = {
render: (args) => ({
components: { VModal, VButton, VInput, VFormField },
setup() {
// Use a local ref for modelValue to simulate v-model behavior within the story
// This allows Storybook controls to set the initial 'modelValue' arg,
// and then the component and story can interact with this local state.
const isModalOpen = ref(args.modelValue);
// Watch for changes from Storybook controls to update local state
watch(() => args.modelValue, (newVal) => {
isModalOpen.value = newVal;
});
// Function to update Storybook arg when local state changes (simulates emit)
const onUpdateModelValue = (val: boolean) => {
isModalOpen.value = val;
// args.modelValue = val; // This would update the control, but can cause loops if not careful
// Storybook's action logger for 'update:modelValue' will show this.
};
return { ...args, isModalOpen, onUpdateModelValue }; // Spread args to pass all other props
},
// Base template structure, specific content will be overridden by each story
template: `
<div>
<VButton @click="isModalOpen = true">Open Modal</VButton>
<VModal
:modelValue="isModalOpen"
@update:modelValue="onUpdateModelValue"
:title="title"
:hideCloseButton="hideCloseButton"
:persistent="persistent"
:size="size"
:idBase="idBase"
@opened="() => $emit('opened')"
@closed="() => $emit('closed')"
@close="() => $emit('close')"
>
<template #header v-if="args.customHeaderSlot">
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h3 style="margin:0;"><em>Custom Header Slot!</em></h3>
<VButton v-if="!hideCloseButton" @click="isModalOpen = false" size="sm" variant="neutral">Close from slot</VButton>
</div>
</template>
<template #default>
<p v-if="args.bodyContent">{{ args.bodyContent }}</p>
<slot name="storyDefaultContent"></slot>
</template>
<template #footer v-if="args.showFooter !== false">
<slot name="storyFooterContent">
<VButton variant="neutral" @click="isModalOpen = false">Cancel</VButton>
<VButton variant="primary" @click="isModalOpen = false">Submit</VButton>
</slot>
</template>
</VModal>
</div>
`,
}),
};
export const Basic: Story = {
...ModalInteractionTemplate,
args: {
modelValue: false, // Initial state for Storybook control
title: 'Basic Modal Title',
bodyContent: 'This is the main content of the modal. You can put any HTML or Vue components here.',
size: 'md',
},
};
export const WithCustomHeader: Story = {
...ModalInteractionTemplate,
args: {
...Basic.args,
title: 'This title is overridden by slot',
customHeaderSlot: true, // Custom arg for story to toggle header slot template
},
};
export const Persistent: Story = {
...ModalInteractionTemplate,
args: {
...Basic.args,
title: 'Persistent Modal',
bodyContent: 'This modal will not close when clicking the backdrop. Use the "Close" button or Escape key.',
persistent: true,
},
};
export const NoCloseButton: Story = {
...ModalInteractionTemplate,
args: {
...Basic.args,
title: 'No "X" Button',
bodyContent: 'The default header close button (X) is hidden. You must provide other means to close it (e.g., footer buttons, Esc key).',
hideCloseButton: true,
},
};
export const SmallSize: Story = {
...ModalInteractionTemplate,
args: {
...Basic.args,
title: 'Small Modal (sm)',
size: 'sm',
bodyContent: 'This modal uses the "sm" size preset for a smaller width.',
},
};
export const LargeSize: Story = {
...ModalInteractionTemplate,
args: {
...Basic.args,
title: 'Large Modal (lg)',
size: 'lg',
bodyContent: 'This modal uses the "lg" size preset for a larger width. Useful for forms or more content.',
},
};
export const WithFormContent: Story = {
...ModalInteractionTemplate,
render: (args) => ({ // Override render for specific slot content
components: { VModal, VButton, VInput, VFormField },
setup() {
const isModalOpen = ref(args.modelValue);
watch(() => args.modelValue, (newVal) => { isModalOpen.value = newVal; });
const onUpdateModelValue = (val: boolean) => { isModalOpen.value = val; };
const username = ref('');
const password = ref('');
return { ...args, isModalOpen, onUpdateModelValue, username, password };
},
template: `
<div>
<VButton @click="isModalOpen = true">Open Form Modal</VButton>
<VModal
:modelValue="isModalOpen"
@update:modelValue="onUpdateModelValue"
:title="title"
:size="size"
>
<VFormField label="Username" forId="modalUser">
<VInput id="modalUser" v-model="username" placeholder="Enter username" />
</VFormField>
<VFormField label="Password" forId="modalPass">
<VInput id="modalPass" type="password" v-model="password" placeholder="Enter password" />
</VFormField>
<template #footer>
<VButton variant="neutral" @click="isModalOpen = false">Cancel</VButton>
<VButton variant="primary" @click="() => { alert('Submitted: ' + username + ' / ' + password); isModalOpen = false; }">Log In</VButton>
</template>
</VModal>
</div>
`,
}),
args: {
...Basic.args,
title: 'Login Form',
showFooter: true, // Ensure default footer with submit/cancel is shown by template logic
},
};
export const ConfirmOnClose: Story = {
render: (args) => ({
components: { VModal, VButton, VInput },
setup() {
const isModalOpen = ref(args.modelValue);
const textInput = ref("Some unsaved data...");
const hasUnsavedChanges = computed(() => textInput.value !== "");
watch(() => args.modelValue, (newVal) => { isModalOpen.value = newVal; });
const requestClose = () => {
if (hasUnsavedChanges.value) {
if (confirm("You have unsaved changes. Are you sure you want to close?")) {
isModalOpen.value = false;
// args.modelValue = false; // Update arg
}
} else {
isModalOpen.value = false;
// args.modelValue = false; // Update arg
}
};
// This simulates the @update:modelValue from VModal,
// but intercepts it for confirmation logic.
const handleModalUpdate = (value: boolean) => {
if (value === false) { // Modal is trying to close
requestClose();
} else {
isModalOpen.value = true;
// args.modelValue = true;
}
};
return { ...args, isModalOpen, textInput, handleModalUpdate, requestClose };
},
template: `
<div>
<VButton @click="isModalOpen = true">Open Modal with Confirmation</VButton>
<VModal
:modelValue="isModalOpen"
@update:modelValue="handleModalUpdate"
:title="title"
:persistent="true"
:hideCloseButton="false"
>
<p>Try to close this modal with text in the input field.</p>
<VInput v-model="textInput" placeholder="Type something here" />
<p v-if="textInput === ''" style="color: green;">No unsaved changes. Modal will close normally.</p>
<p v-else style="color: orange;">Unsaved changes detected!</p>
<template #footer>
<VButton variant="neutral" @click="requestClose">Attempt Close</VButton>
<VButton variant="primary" @click="() => { textInput = ''; alert('Changes saved (simulated)'); }">Save Changes</VButton>
</template>
</VModal>
</div>
`,
}),
args: {
...Basic.args,
title: 'Confirm Close Modal',
bodyContent: '', // Content is in the template for this story
// persistent: true, // Good for confirm on close so backdrop click doesn't bypass confirm
// hideCloseButton: true, // Also good for confirm on close
},
};

View File

@ -0,0 +1,245 @@
<template>
<Teleport to="body">
<Transition
name="modal-fade"
@after-enter="onOpened"
@after-leave="onClosed"
>
<div
v-if="modelValue"
class="modal-backdrop"
@click="handleBackdropClick"
>
<div
class="modal-container"
:class="['modal-container-' + size, { 'open': modelValue }]"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="bodyId"
@click.stop
>
<div v-if="$slots.header || title || !hideCloseButton" class="modal-header">
<slot name="header">
<h3 v-if="title" :id="titleId" class="modal-title">{{ title }}</h3>
<button
v-if="!hideCloseButton"
type="button"
class="close-button"
@click="closeModal"
aria-label="Close modal"
>
<VIcon name="close" />
</button>
</slot>
</div>
<div class="modal-body" :id="bodyId">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, watch, onMounted, onBeforeUnmount, ref } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon is available
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
title: {
type: String,
default: null,
},
hideCloseButton: {
type: Boolean,
default: false,
},
persistent: {
type: Boolean,
default: false,
},
size: {
type: String, // 'sm', 'md', 'lg'
default: 'md',
validator: (value: string) => ['sm', 'md', 'lg'].includes(value),
},
idBase: {
type: String,
default: null,
},
});
const emit = defineEmits(['update:modelValue', 'close', 'opened', 'closed']);
const uniqueComponentId = ref(`modal-${Math.random().toString(36).substring(2, 9)}`);
const titleId = computed(() => props.idBase ? `${props.idBase}-title` : `${uniqueComponentId.value}-title`);
const bodyId = computed(() => props.idBase ? `${props.idBase}-description` : `${uniqueComponentId.value}-description`);
const closeModal = () => {
emit('update:modelValue', false);
emit('close');
};
const handleBackdropClick = () => {
if (!props.persistent) {
closeModal();
}
};
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.modelValue) {
closeModal();
}
};
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
document.body.classList.add('modal-open');
document.addEventListener('keydown', handleEscKey);
} else {
document.body.classList.remove('modal-open');
document.removeEventListener('keydown', handleEscKey);
}
});
// Cleanup listener if component is unmounted while modal is open
onBeforeUnmount(() => {
if (props.modelValue) {
document.body.classList.remove('modal-open');
document.removeEventListener('keydown', handleEscKey);
}
});
const onOpened = () => {
emit('opened');
};
const onClosed = () => {
emit('closed');
};
</script>
<style lang="scss" scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050; // Ensure it's above most other content
}
.modal-container {
background-color: #fff;
border-radius: 0.375rem; // 6px
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
max-height: 90vh; // Prevent modal from being too tall
overflow: hidden; // Needed for children with overflow (e.g. scrollable body)
// Default size (md)
width: 500px; // Example, adjust as needed
max-width: 90%;
&.modal-container-sm {
width: 300px;
}
&.modal-container-lg {
width: 800px;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e0e0e0; // Example border
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 500;
}
.close-button {
background: transparent;
border: none;
font-size: 1.5rem; // Make VIcon larger if it inherits font-size
padding: 0.25rem;
margin: -0.25rem; // Adjust for padding to align visual edge
line-height: 1;
cursor: pointer;
color: #6c757d; // Muted color
&:hover {
color: #343a40;
}
// VIcon specific styling if needed, e.g., for stroke width or size
// ::v-deep(.icon) { font-size: 1.2em; }
}
}
.modal-body {
padding: 1.25rem;
overflow-y: auto; // Scrollable body if content exceeds max-height
flex-grow: 1; // Allow body to take available space
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end; // Common: buttons to the right
padding: 1rem 1.25rem;
border-top: 1px solid #e0e0e0;
gap: 0.5rem; // Space between footer items (buttons)
}
// Transitions
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-active .modal-container,
.modal-fade-leave-active .modal-container {
transition: transform 0.3s ease-out; // Slightly different timing for container
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .modal-container,
.modal-fade-leave-to .modal-container {
transform: translateY(-50px) scale(0.95); // Example: slide down and scale
}
// This class is applied to <body>
// ::v-global(body.modal-open) {
// overflow: hidden;
// }
// Note: ::v-global is not standard. This is typically handled in main CSS or via JS on body.
// The JS part `document.body.classList.add('modal-open')` is already there.
// The style for `body.modal-open` should be in a global stylesheet.
// For demo purposes, if it were here (which it shouldn't be):
// :global(body.modal-open) {
// overflow: hidden;
// }
</style>

View File

@ -0,0 +1,93 @@
import { mount } from '@vue/test-utils';
import VProgressBar from './VProgressBar.vue';
import { describe, it, expect } from 'vitest';
describe('VProgressBar.vue', () => {
it('calculates percentage and sets width style correctly', () => {
const wrapper = mount(VProgressBar, { props: { value: 50, max: 100 } });
const bar = wrapper.find('.progress-bar');
expect(bar.attributes('style')).toContain('width: 50%;');
});
it('calculates percentage correctly with different max values', () => {
const wrapper = mount(VProgressBar, { props: { value: 10, max: 20 } }); // 50%
expect(wrapper.find('.progress-bar').attributes('style')).toContain('width: 50%;');
});
it('caps percentage at 100% if value exceeds max', () => {
const wrapper = mount(VProgressBar, { props: { value: 150, max: 100 } });
expect(wrapper.find('.progress-bar').attributes('style')).toContain('width: 100%;');
});
it('caps percentage at 0% if value is negative', () => {
const wrapper = mount(VProgressBar, { props: { value: -50, max: 100 } });
expect(wrapper.find('.progress-bar').attributes('style')).toContain('width: 0%;');
});
it('handles max value of 0 or less by setting width to 0%', () => {
const wrapperZeroMax = mount(VProgressBar, { props: { value: 50, max: 0 } });
expect(wrapperZeroMax.find('.progress-bar').attributes('style')).toContain('width: 0%;');
const wrapperNegativeMax = mount(VProgressBar, { props: { value: 50, max: -10 } });
expect(wrapperNegativeMax.find('.progress-bar').attributes('style')).toContain('width: 0%;');
});
it('shows progress text by default', () => {
const wrapper = mount(VProgressBar, { props: { value: 30 } });
expect(wrapper.find('.progress-text').exists()).toBe(true);
expect(wrapper.find('.progress-text').text()).toBe('30%');
});
it('hides progress text when showText is false', () => {
const wrapper = mount(VProgressBar, { props: { value: 30, showText: false } });
expect(wrapper.find('.progress-text').exists()).toBe(false);
});
it('displays custom valueText when provided', () => {
const customText = 'Step 1 of 3';
const wrapper = mount(VProgressBar, {
props: { value: 33, valueText: customText },
});
expect(wrapper.find('.progress-text').text()).toBe(customText);
});
it('displays percentage with zero decimal places by default', () => {
const wrapper = mount(VProgressBar, { props: { value: 33.333, max: 100 } });
expect(wrapper.find('.progress-text').text()).toBe('33%'); // toFixed(0)
});
it('applies "striped" class by default', () => {
const wrapper = mount(VProgressBar, { props: { value: 50 } });
expect(wrapper.find('.progress-bar').classes()).toContain('striped');
});
it('does not apply "striped" class when striped prop is false', () => {
const wrapper = mount(VProgressBar, { props: { value: 50, striped: false } });
expect(wrapper.find('.progress-bar').classes()).not.toContain('striped');
});
it('sets ARIA attributes correctly', () => {
const labelText = 'Upload progress';
const wrapper = mount(VProgressBar, {
props: { value: 60, max: 100, label: labelText },
});
const container = wrapper.find('.progress-container');
expect(container.attributes('role')).toBe('progressbar');
expect(container.attributes('aria-valuenow')).toBe('60');
expect(container.attributes('aria-valuemin')).toBe('0');
expect(container.attributes('aria-valuemax')).toBe('100');
expect(container.attributes('aria-label')).toBe(labelText);
});
it('sets default ARIA label if label prop is not provided', () => {
const wrapper = mount(VProgressBar, { props: { value: 10 } });
expect(wrapper.find('.progress-container').attributes('aria-label')).toBe('Progress indicator');
});
it('has .progress-container and .progress-bar classes', () => {
const wrapper = mount(VProgressBar, { props: { value: 10 } });
expect(wrapper.find('.progress-container').exists()).toBe(true);
expect(wrapper.find('.progress-bar').exists()).toBe(true);
});
});

View File

@ -0,0 +1,166 @@
import VProgressBar from './VProgressBar.vue';
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref }
from 'vue'; // For interactive stories if needed
const meta: Meta<typeof VProgressBar> = {
title: 'Valerie/VProgressBar',
component: VProgressBar,
tags: ['autodocs'],
argTypes: {
value: { control: { type: 'range', min: 0, max: 100, step: 1 }, description: 'Current progress value.' }, // Assuming max=100 for control simplicity
max: { control: 'number', description: 'Maximum progress value.' },
showText: { control: 'boolean' },
striped: { control: 'boolean' },
label: { control: 'text', description: 'Accessible label for the progress bar.' },
valueText: { control: 'text', description: 'Custom text to display instead of percentage.' },
},
parameters: {
docs: {
description: {
component: 'A progress bar component to display the current completion status of a task. Supports customizable text, stripes, and ARIA attributes.',
},
},
},
// Decorator to provide a container for better visualization if needed
// decorators: [() => ({ template: '<div style="width: 300px; padding: 20px;"><story/></div>' })],
};
export default meta;
type Story = StoryObj<typeof VProgressBar>;
export const DefaultAt25Percent: Story = {
args: {
value: 25,
max: 100,
label: 'Task progress',
},
};
export const At0Percent: Story = {
args: {
...DefaultAt25Percent.args,
value: 0,
},
};
export const At50Percent: Story = {
args: {
...DefaultAt25Percent.args,
value: 50,
},
};
export const At75Percent: Story = {
args: {
...DefaultAt25Percent.args,
value: 75,
},
};
export const At100Percent: Story = {
args: {
...DefaultAt25Percent.args,
value: 100,
},
};
export const NoText: Story = {
args: {
...DefaultAt25Percent.args,
value: 60,
showText: false,
label: 'Loading data (visual only)',
},
};
export const NoStripes: Story = {
args: {
...DefaultAt25Percent.args,
value: 70,
striped: false,
label: 'Download status (no stripes)',
},
};
export const CustomMaxValue: Story = {
args: {
value: 10,
max: 20, // Max is 20, so 10 is 50%
label: 'Steps completed',
},
};
export const WithCustomValueText: Story = {
args: {
value: 3,
max: 5,
valueText: 'Step 3 of 5',
label: 'Onboarding process',
},
};
export const ValueOverMax: Story = {
args: {
...DefaultAt25Percent.args,
value: 150, // Should be capped at 100%
label: 'Overloaded progress',
},
};
export const NegativeValue: Story = {
args: {
...DefaultAt25Percent.args,
value: -20, // Should be capped at 0%
label: 'Invalid progress',
},
};
// Interactive story example (optional, if manual controls aren't enough)
export const InteractiveUpdate: Story = {
render: (args) => ({
components: { VProgressBar },
setup() {
const currentValue = ref(args.value || 10);
const intervalId = ref<NodeJS.Timeout | null>(null);
const startProgress = () => {
if (intervalId.value) clearInterval(intervalId.value);
currentValue.value = 0;
intervalId.value = setInterval(() => {
currentValue.value += 10;
if (currentValue.value >= (args.max || 100)) {
currentValue.value = args.max || 100;
if (intervalId.value) clearInterval(intervalId.value);
}
}, 500);
};
onBeforeUnmount(() => {
if (intervalId.value) clearInterval(intervalId.value);
});
return { ...args, currentValue, startProgress };
},
template: `
<div>
<VProgressBar
:value="currentValue"
:max="max"
:showText="showText"
:striped="striped"
:label="label"
:valueText="valueText"
/>
<button @click="startProgress" style="margin-top: 10px;">Start/Restart Progress</button>
</div>
`,
}),
args: {
value: 10, // Initial value for the ref
max: 100,
showText: true,
striped: true,
label: 'Dynamic Progress',
},
};

View File

@ -0,0 +1,125 @@
<template>
<div
class="progress-container"
role="progressbar"
:aria-valuenow="percentage"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="label || 'Progress indicator'"
>
<div
class="progress-bar"
:style="{ width: percentage + '%' }"
:class="{ 'striped': striped }"
>
<span v-if="showText" class="progress-text">
{{ displayText }}
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps({
value: {
type: Number,
required: true,
validator: (val: number) => !isNaN(val), // Basic check for valid number
},
max: {
type: Number,
default: 100,
validator: (val: number) => val > 0 && !isNaN(val),
},
showText: {
type: Boolean,
default: true,
},
striped: {
type: Boolean,
default: true,
},
label: {
type: String,
default: null, // Default aria-label is set in template if this is null
},
valueText: { // Custom text to display instead of percentage
type: String,
default: null,
},
});
const percentage = computed(() => {
if (props.max <= 0) return 0; // Avoid division by zero or negative max
const calculated = (props.value / props.max) * 100;
return Math.max(0, Math.min(100, calculated)); // Clamp between 0 and 100
});
const displayText = computed(() => {
if (props.valueText !== null) {
return props.valueText;
}
// You might want to adjust decimal places based on precision needed
return `${percentage.value.toFixed(0)}%`;
});
</script>
<style lang="scss" scoped>
// Assuming --progress-texture is defined in valerie-ui.scss or globally
// For example:
// :root {
// --progress-texture: repeating-linear-gradient(
// 45deg,
// rgba(255, 255, 255, 0.15),
// rgba(255, 255, 255, 0.15) 10px,
// transparent 10px,
// transparent 20px
// );
// --progress-bar-bg: #007bff; // Example primary color
// --progress-bar-text-color: #fff;
// --progress-container-bg: #e9ecef;
// }
.progress-container {
width: 100%;
height: 1.25rem; // Default height, adjust as needed
background-color: var(--progress-container-bg, #e9ecef); // Fallback color
border-radius: 0.25rem; // Rounded corners for the container
overflow: hidden; // Ensure progress-bar respects container's border-radius
position: relative; // For positioning text if needed outside the bar
}
.progress-bar {
height: 100%;
background-color: var(--progress-bar-bg, #007bff); // Fallback color
display: flex;
align-items: center;
justify-content: center; // Center text if it's inside the bar
transition: width 0.3s ease-out; // Smooth transition for width changes
color: var(--progress-bar-text-color, #fff); // Text color for text inside the bar
font-size: 0.75rem; // Smaller font for progress text
line-height: 1; // Ensure text is vertically centered
&.striped {
// The variable --progress-texture should be defined in valerie-ui.scss
// or a global style sheet.
// Example: repeating-linear-gradient(45deg, rgba(255,255,255,.15), rgba(255,255,255,.15) 10px, transparent 10px, transparent 20px)
background-image: var(--progress-texture);
background-size: 28.28px 28.28px; // Adjust size for desired stripe density (sqrt(20^2+20^2)) if texture is 20px based
// Or simply use a fixed size like 40px 40px if the gradient is designed for that
}
}
.progress-text {
// Styling for the text. If it's always centered by .progress-bar,
// specific positioning might not be needed.
// Consider contrast, especially if bar width is small.
// One option is to have text outside the bar if it doesn't fit or for contrast.
// For now, it's centered within the bar.
white-space: nowrap;
padding: 0 0.25rem; // Small padding if text is very close to edges
}
</style>

View File

@ -0,0 +1,129 @@
import { mount } from '@vue/test-utils';
import VRadio from './VRadio.vue';
import { describe, it, expect } from 'vitest';
describe('VRadio.vue', () => {
it('binds modelValue, reflects checked state, and emits update:modelValue', async () => {
const wrapper = mount(VRadio, {
props: {
modelValue: 'initialGroupValue', // This radio is not selected initially
value: 'thisRadioValue',
name: 'testGroup',
id: 'test-radio1',
},
});
const inputElement = wrapper.find('input[type="radio"]');
// Initial state (not checked)
expect(inputElement.element.checked).toBe(false);
// Simulate parent selecting this radio button
await wrapper.setProps({ modelValue: 'thisRadioValue' });
expect(inputElement.element.checked).toBe(true);
// Simulate user clicking this radio (which is already selected by parent)
// No change event if already checked and clicked again (browser behavior)
// So, let's test selection from an unselected state by changing modelValue first
await wrapper.setProps({ modelValue: 'anotherValue' });
expect(inputElement.element.checked).toBe(false); // Ensure it's unselected
// Simulate user clicking this radio button to select it
// Note: setChecked() on a radio in a group might not trigger change as expected in JSDOM
// A direct .trigger('change') is more reliable for unit testing radio logic.
// Or, if the radio is part of a group, only one can be checked.
// The component's logic is that if it's clicked, it emits its value.
// Manually trigger change as if user clicked THIS radio specifically
await inputElement.trigger('change');
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
// The last emission (or first if only one) should be its own value
const emissions = wrapper.emitted()['update:modelValue'];
expect(emissions[emissions.length -1]).toEqual(['thisRadioValue']);
// After emitting, if the parent updates modelValue, it should reflect
await wrapper.setProps({ modelValue: 'thisRadioValue' });
expect(inputElement.element.checked).toBe(true);
});
it('is checked when modelValue matches its value', () => {
const wrapper = mount(VRadio, {
props: { modelValue: 'selectedVal', value: 'selectedVal', name: 'group' },
});
expect(wrapper.find('input[type="radio"]').element.checked).toBe(true);
});
it('is not checked when modelValue does not match its value', () => {
const wrapper = mount(VRadio, {
props: { modelValue: 'otherVal', value: 'thisVal', name: 'group' },
});
expect(wrapper.find('input[type="radio"]').element.checked).toBe(false);
});
it('renders label when label prop is provided', () => {
const labelText = 'Select this radio';
const wrapper = mount(VRadio, {
props: { modelValue: '', value: 'any', name: 'group', label: labelText },
});
const labelElement = wrapper.find('.radio-text-label');
expect(labelElement.exists()).toBe(true);
expect(labelElement.text()).toBe(labelText);
});
it('is disabled when disabled prop is true', () => {
const wrapper = mount(VRadio, {
props: { modelValue: '', value: 'any', name: 'group', disabled: true },
});
expect(wrapper.find('input[type="radio"]').attributes('disabled')).toBeDefined();
expect(wrapper.find('.radio-label').classes()).toContain('disabled');
});
it('applies name and value attributes correctly', () => {
const nameVal = 'contactPreference';
const valueVal = 'email';
const wrapper = mount(VRadio, {
props: { modelValue: '', value: valueVal, name: nameVal },
});
const input = wrapper.find('input[type="radio"]');
expect(input.attributes('name')).toBe(nameVal);
expect(input.attributes('value')).toBe(valueVal);
});
it('passes id prop to input and label for attribute if provided', () => {
const radioId = 'my-custom-radio-id';
const wrapper = mount(VRadio, {
props: { modelValue: '', value: 'any', name: 'group', id: radioId },
});
expect(wrapper.find('input[type="radio"]').attributes('id')).toBe(radioId);
expect(wrapper.find('.radio-label').attributes('for')).toBe(radioId);
});
it('generates an effectiveId if id prop is not provided', () => {
const wrapper = mount(VRadio, {
props: { modelValue: '', value: 'valX', name: 'groupY' },
});
const expectedId = 'vradio-groupY-valX';
expect(wrapper.find('input[type="radio"]').attributes('id')).toBe(expectedId);
expect(wrapper.find('.radio-label').attributes('for')).toBe(expectedId);
});
it('contains a .checkmark.radio-mark span', () => {
const wrapper = mount(VRadio, { props: { modelValue: '', value: 'any', name: 'group' } });
expect(wrapper.find('.checkmark.radio-mark').exists()).toBe(true);
});
it('adds "checked" class to label when radio is checked', () => {
const wrapper = mount(VRadio, {
props: { modelValue: 'thisValue', value: 'thisValue', name: 'group' },
});
expect(wrapper.find('.radio-label').classes()).toContain('checked');
});
it('does not add "checked" class to label when radio is not checked', () => {
const wrapper = mount(VRadio, {
props: { modelValue: 'otherValue', value: 'thisValue', name: 'group' },
});
expect(wrapper.find('.radio-label').classes()).not.toContain('checked');
});
});

View File

@ -0,0 +1,176 @@
import VRadio from './VRadio.vue';
import VFormField from './VFormField.vue'; // For context if showing errors related to a radio group
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref, watch } from 'vue';
const meta: Meta<typeof VRadio> = {
title: 'Valerie/VRadio',
component: VRadio,
tags: ['autodocs'],
argTypes: {
modelValue: { control: 'text', description: 'Current selected value in the radio group (v-model).' },
value: { control: 'text', description: 'The unique value this radio button represents.' },
label: { control: 'text' },
disabled: { control: 'boolean' },
id: { control: 'text' },
name: { control: 'text', description: 'HTML `name` attribute for grouping.' },
// 'update:modelValue': { action: 'updated' }
},
parameters: {
docs: {
description: {
component: 'A custom radio button component. Group multiple VRadio components with the same `name` prop and bind them to the same `v-model` for a radio group.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VRadio>;
// Template for a single VRadio instance, primarily for showing individual states
const SingleRadioTemplate: Story = {
render: (args) => ({
components: { VRadio },
setup() {
const storyValue = ref(args.modelValue);
watch(() => args.modelValue, (newVal) => {
storyValue.value = newVal;
});
const onChange = (newValue: string | number) => {
storyValue.value = newValue;
// args.modelValue = newValue; // Update Storybook arg
}
return { args, storyValue, onChange };
},
template: '<VRadio v-bind="args" :modelValue="storyValue" @update:modelValue="onChange" />',
}),
};
// Story for a group of radio buttons
export const RadioGroup: Story = {
render: (args) => ({
components: { VRadio },
setup() {
const selectedValue = ref(args.groupModelValue || 'opt2'); // Default selected value for the group
// This simulates how a parent component would handle the v-model for the group
return { args, selectedValue };
},
template: `
<div>
<VRadio
v-for="option in args.options"
:key="option.value"
:id="'radio-' + args.name + '-' + option.value"
:name="args.name"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
v-model="selectedValue"
/>
<p style="margin-top: 10px;">Selected: {{ selectedValue }}</p>
</div>
`,
}),
args: {
name: 'storyGroup',
groupModelValue: 'opt2', // Initial selected value for the group
options: [
{ value: 'opt1', label: 'Option 1' },
{ value: 'opt2', label: 'Option 2 (Default)' },
{ value: 'opt3', label: 'Option 3 (Longer Label)' },
{ value: 'opt4', label: 'Option 4 (Disabled)', disabled: true },
{ value: 5, label: 'Option 5 (Number value)'}
],
},
parameters: {
docs: {
description: { story: 'A group of `VRadio` components. They share the same `name` and `v-model` (here `selectedValue`).' },
},
},
};
export const WithLabel: Story = {
...SingleRadioTemplate,
args: {
id: 'labelledRadio',
name: 'single',
modelValue: 'myValue', // This radio is selected because modelValue === value
value: 'myValue',
label: 'Choose this option',
},
};
export const DisabledUnselected: Story = {
...SingleRadioTemplate,
args: {
id: 'disabledUnselectedRadio',
name: 'singleDisabled',
modelValue: 'anotherValue', // This radio is not selected
value: 'thisValue',
label: 'Cannot select this',
disabled: true,
},
};
export const DisabledSelected: Story = {
...SingleRadioTemplate,
args: {
id: 'disabledSelectedRadio',
name: 'singleDisabled',
modelValue: 'thisValueSelected', // This radio IS selected
value: 'thisValueSelected',
label: 'Selected and disabled',
disabled: true,
},
};
// It's less common to use VRadio directly in VFormField for its label,
// but VFormField could provide an error message for a radio group.
export const GroupInFormFieldForError: Story = {
render: (args) => ({
components: { VRadio, VFormField },
setup() {
const selectedValue = ref(args.groupModelValue || null);
return { args, selectedValue };
},
template: `
<VFormField :label="args.formFieldArgs.label" :errorMessage="args.formFieldArgs.errorMessage">
<div role="radiogroup" :aria-labelledby="args.formFieldArgs.label ? args.formFieldArgs.labelId : undefined">
<VRadio
v-for="option in args.options"
:key="option.value"
:id="'radio-ff-' + args.name + '-' + option.value"
:name="args.name"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
v-model="selectedValue"
/>
</div>
<p v-if="selectedValue" style="margin-top: 10px;">Selected: {{ selectedValue }}</p>
</VFormField>
`,
}),
args: {
name: 'formFieldRadioGroup',
groupModelValue: null, // Start with no selection
options: [
{ value: 'ffOpt1', label: 'Option A' },
{ value: 'ffOpt2', label: 'Option B' },
],
formFieldArgs: {
labelId: 'radioGroupLabel', // An ID for the label if VFormField label is used as group label
label: 'Please make a selection:',
errorMessage: 'A selection is required for this group.',
}
},
parameters: {
docs: {
description: { story: 'A radio group within `VFormField`. `VFormField` can provide a group label (via `aria-labelledby`) and display error messages related to the group.' },
},
},
};

View File

@ -0,0 +1,165 @@
<template>
<label :class="labelClasses" :for="effectiveId">
<input
type="radio"
:id="effectiveId"
:name="name"
:value="value"
:checked="isChecked"
:disabled="disabled"
@change="onChange"
/>
<span class="checkmark radio-mark"></span>
<span v-if="label" class="radio-text-label">{{ label }}</span>
</label>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
export default defineComponent({
name: 'VRadio',
props: {
modelValue: {
type: [String, Number] as PropType<string | number | null>, // Allow null for when nothing is selected
required: true,
},
value: {
type: [String, Number] as PropType<string | number>,
required: true,
},
label: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
name: {
type: String,
required: true,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const effectiveId = computed(() => {
return props.id || `vradio-${props.name}-${props.value}`;
});
const labelClasses = computed(() => [
'radio-label',
{ 'disabled': props.disabled },
{ 'checked': isChecked.value }, // For potential styling of the label itself when checked
]);
const isChecked = computed(() => {
return props.modelValue === props.value;
});
const onChange = () => {
if (!props.disabled) {
emit('update:modelValue', props.value);
}
};
return {
effectiveId,
labelClasses,
isChecked,
onChange,
};
},
});
</script>
<style lang="scss" scoped>
// Styles are very similar to VCheckbox, with adjustments for radio appearance (circle)
.radio-label {
display: inline-flex;
align-items: center;
cursor: pointer;
position: relative;
user-select: none;
padding-left: 28px; // Space for the custom radio mark
min-height: 20px;
font-size: 1rem;
margin-right: 10px; // Spacing between radio buttons in a group
input[type="radio"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
height: 20px;
width: 20px;
background-color: #fff;
border: 1px solid #adb5bd;
border-radius: 50%; // Makes it a circle for radio
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
// Radio mark's inner dot (hidden when not checked)
&.radio-mark:after {
content: "";
position: absolute;
display: none;
top: 50%;
left: 50%;
width: 10px; // Size of the inner dot
height: 10px;
border-radius: 50%;
background: white;
transform: translate(-50%, -50%);
}
}
input[type="radio"]:checked ~ .checkmark {
background-color: #007bff;
border-color: #007bff;
}
input[type="radio"]:checked ~ .checkmark.radio-mark:after {
display: block;
}
input[type="radio"]:focus ~ .checkmark {
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
&.disabled {
cursor: not-allowed;
opacity: 0.7;
input[type="radio"]:disabled ~ .checkmark {
background-color: #e9ecef;
border-color: #ced4da;
}
input[type="radio"]:disabled:checked ~ .checkmark {
background-color: #7badec; // Lighter primary for disabled checked
border-color: #7badec;
}
input[type="radio"]:disabled:checked ~ .checkmark.radio-mark:after {
background: #e9ecef; // Match disabled background or a lighter contrast
}
}
.radio-text-label {
// margin-left: 0.5rem; // Similar to checkbox, handled by padding-left on root
vertical-align: middle;
}
}
</style>

View File

@ -0,0 +1,132 @@
import { mount } from '@vue/test-utils';
import VSelect from './VSelect.vue';
import { describe, it, expect } from 'vitest';
const testOptions = [
{ value: 'val1', label: 'Label 1' },
{ value: 'val2', label: 'Label 2', disabled: true },
{ value: 3, label: 'Label 3 (number)' }, // Numeric value
];
describe('VSelect.vue', () => {
it('binds modelValue and emits update:modelValue correctly (v-model)', async () => {
const wrapper = mount(VSelect, {
props: { modelValue: 'val1', options: testOptions },
});
const selectElement = wrapper.find('select');
// Check initial value
expect(selectElement.element.value).toBe('val1');
// Simulate user changing selection
await selectElement.setValue('val2'); // This will select the option with value "val2"
// Check emitted event
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['val2']);
// Simulate parent v-model update
await wrapper.setProps({ modelValue: 'val1' });
expect(selectElement.element.value).toBe('val1');
});
it('correctly emits numeric value when a number option is selected', async () => {
const wrapper = mount(VSelect, {
props: { modelValue: '', options: testOptions, placeholder: 'Select...' },
});
const selectElement = wrapper.find('select');
await selectElement.setValue('3'); // Value of 'Label 3 (number)' is 3 (a number)
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([3]); // Should emit the number 3
});
it('renders options correctly with labels, values, and disabled states', () => {
const wrapper = mount(VSelect, {
props: { modelValue: '', options: testOptions },
});
const optionElements = wrapper.findAll('option');
expect(optionElements.length).toBe(testOptions.length);
testOptions.forEach((opt, index) => {
const optionElement = optionElements[index];
expect(optionElement.attributes('value')).toBe(String(opt.value));
expect(optionElement.text()).toBe(opt.label);
if (opt.disabled) {
expect(optionElement.attributes('disabled')).toBeDefined();
} else {
expect(optionElement.attributes('disabled')).toBeUndefined();
}
});
});
it('renders a placeholder option when placeholder prop is provided', () => {
const placeholderText = 'Choose...';
const wrapper = mount(VSelect, {
props: { modelValue: '', options: testOptions, placeholder: placeholderText },
});
const placeholderOption = wrapper.find('option[value=""]');
expect(placeholderOption.exists()).toBe(true);
expect(placeholderOption.text()).toBe(placeholderText);
expect(placeholderOption.attributes('disabled')).toBeDefined();
// Check if it's selected when modelValue is empty
expect(placeholderOption.element.selected).toBe(true);
});
it('placeholder is not selected if modelValue has a value', () => {
const placeholderText = 'Choose...';
const wrapper = mount(VSelect, {
props: { modelValue: 'val1', options: testOptions, placeholder: placeholderText },
});
const placeholderOption = wrapper.find('option[value=""]');
expect(placeholderOption.element.selected).toBe(false);
const selectedVal1 = wrapper.find('option[value="val1"]');
expect(selectedVal1.element.selected).toBe(true);
});
it('is disabled when disabled prop is true', () => {
const wrapper = mount(VSelect, {
props: { modelValue: '', options: testOptions, disabled: true },
});
expect(wrapper.find('select').attributes('disabled')).toBeDefined();
});
it('is required when required prop is true', () => {
const wrapper = mount(VSelect, {
props: { modelValue: '', options: testOptions, required: true },
});
expect(wrapper.find('select').attributes('required')).toBeDefined();
});
it('applies error class and aria-invalid when error prop is true', () => {
const wrapper = mount(VSelect, {
props: { modelValue: '', options: testOptions, error: true },
});
const select = wrapper.find('select');
expect(select.classes()).toContain('form-input');
expect(select.classes()).toContain('select');
expect(select.classes()).toContain('error');
expect(select.attributes('aria-invalid')).toBe('true');
});
it('does not apply error class or aria-invalid by default', () => {
const wrapper = mount(VSelect, { props: { modelValue: '', options: testOptions } });
const select = wrapper.find('select');
expect(select.classes()).not.toContain('error');
expect(select.attributes('aria-invalid')).toBeNull();
});
it('passes id prop to the select element', () => {
const selectId = 'my-custom-select-id';
const wrapper = mount(VSelect, {
props: { modelValue: '', options: testOptions, id: selectId },
});
expect(wrapper.find('select').attributes('id')).toBe(selectId);
});
it('has "select" and "form-input" classes', () => {
const wrapper = mount(VSelect, { props: { modelValue: '', options: testOptions } });
expect(wrapper.find('select').classes()).toContain('select');
expect(wrapper.find('select').classes()).toContain('form-input');
});
});

View File

@ -0,0 +1,197 @@
import VSelect from './VSelect.vue';
import VFormField from './VFormField.vue'; // To demonstrate usage with VFormField
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue'; // For v-model in stories
const sampleOptions = [
{ value: '', label: 'Select an option (from options prop, if placeholder not used)' , disabled: true},
{ value: 'opt1', label: 'Option 1' },
{ value: 'opt2', label: 'Option 2 (Longer Text)' },
{ value: 'opt3', label: 'Option 3', disabled: true },
{ value: 4, label: 'Option 4 (Number Value)' }, // Example with number value
{ value: 'opt5', label: 'Option 5' },
];
const meta: Meta<typeof VSelect> = {
title: 'Valerie/VSelect',
component: VSelect,
tags: ['autodocs'],
argTypes: {
modelValue: { control: 'select', options: ['', 'opt1', 'opt2', 4, 'opt5'], description: 'Bound value using v-model. Control shows possible values from sampleOptions.' },
options: { control: 'object' },
disabled: { control: 'boolean' },
required: { control: 'boolean' },
error: { control: 'boolean', description: 'Applies error styling.' },
id: { control: 'text' },
placeholder: { control: 'text' },
// 'update:modelValue': { action: 'updated' }
},
parameters: {
docs: {
description: {
component: 'A select component for choosing from a list of options, supporting v-model, states, and placeholder.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VSelect>;
// Template for v-model interaction in stories
const VModelTemplate: Story = {
render: (args) => ({
components: { VSelect },
setup() {
// Storybook's args are reactive. For v-model, ensure the control updates the arg.
// If direct v-model="args.modelValue" has issues, a local ref can be used.
const storyValue = ref(args.modelValue); // Initialize with current arg value
const onChange = (newValue: string | number) => {
storyValue.value = newValue; // Update local ref
// args.modelValue = newValue; // This would update the arg if mutable, SB controls should handle this
}
// Watch for external changes to modelValue from Storybook controls
watch(() => args.modelValue, (newVal) => {
storyValue.value = newVal;
});
return { args, storyValue, onChange };
},
template: '<VSelect v-bind="args" :modelValue="storyValue" @update:modelValue="onChange" />',
}),
};
export const Basic: Story = {
...VModelTemplate,
args: {
id: 'basicSelect',
options: sampleOptions.filter(opt => !opt.disabled || opt.value === ''), // Filter out pre-disabled for basic
modelValue: 'opt1',
},
};
export const WithPlaceholder: Story = {
...VModelTemplate,
args: {
id: 'placeholderSelect',
options: sampleOptions.filter(opt => opt.value !== ''), // Remove the empty value option from main list if placeholder is used
modelValue: '', // Placeholder should be selected
placeholder: 'Please choose an item...',
},
};
export const Disabled: Story = {
...VModelTemplate,
args: {
id: 'disabledSelect',
options: sampleOptions,
modelValue: 'opt2',
disabled: true,
},
};
export const Required: Story = {
...VModelTemplate,
args: {
id: 'requiredSelect',
options: sampleOptions.filter(opt => opt.value !== ''),
modelValue: '', // Start with nothing selected if required and placeholder exists
placeholder: 'You must select one',
required: true,
},
parameters: {
docs: {
description: { story: 'The `required` attribute is set. If a placeholder is present and selected, form validation may fail as expected.' },
},
},
};
export const ErrorState: Story = {
...VModelTemplate,
args: {
id: 'errorSelect',
options: sampleOptions,
modelValue: 'opt1',
error: true,
},
};
export const WithDisabledOptions: Story = {
...VModelTemplate,
args: {
id: 'disabledOptionsSelect',
options: sampleOptions, // sampleOptions already includes a disabled option (opt3)
modelValue: 'opt1',
},
};
export const NumberValueSelected: Story = {
...VModelTemplate,
args: {
id: 'numberValueSelect',
options: sampleOptions,
modelValue: 4, // Corresponds to 'Option 4 (Number Value)'
},
};
// Story demonstrating VSelect used within VFormField
export const InFormField: Story = {
render: (args) => ({
components: { VSelect, VFormField },
setup() {
const storyValue = ref(args.selectArgs.modelValue);
watch(() => args.selectArgs.modelValue, (newVal) => {
storyValue.value = newVal;
});
const onChange = (newValue: string | number) => {
storyValue.value = newValue;
}
return { args, storyValue, onChange };
},
template: `
<VFormField :label="args.formFieldArgs.label" :forId="args.selectArgs.id" :errorMessage="args.formFieldArgs.errorMessage">
<VSelect
v-bind="args.selectArgs"
:modelValue="storyValue"
@update:modelValue="onChange"
/>
</VFormField>
`,
}),
args: {
formFieldArgs: {
label: 'Choose Category',
errorMessage: '',
},
selectArgs: {
id: 'categorySelect',
options: sampleOptions.filter(opt => opt.value !== ''),
modelValue: '',
placeholder: 'Select a category...',
error: false,
},
},
parameters: {
docs: {
description: { story: '`VSelect` used inside a `VFormField`. The `id` on `VSelect` should match `forId` on `VFormField`.' },
},
},
};
export const InFormFieldWithError: Story = {
...InFormField, // Inherit render function
args: {
formFieldArgs: {
label: 'Select Priority',
errorMessage: 'A priority must be selected.',
},
selectArgs: {
id: 'prioritySelectError',
options: sampleOptions.filter(opt => opt.value !== ''),
modelValue: '', // Nothing selected, causing error
placeholder: 'Choose priority...',
error: true, // Set VSelect's error state
required: true,
},
},
};

View File

@ -0,0 +1,158 @@
<template>
<select
:id="id"
:value="modelValue"
:disabled="disabled"
:required="required"
:class="selectClasses"
:aria-invalid="error ? 'true' : null"
@change="onChange"
>
<option v-if="placeholder" value="" disabled :selected="!modelValue">
{{ placeholder }}
</option>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
interface SelectOption {
value: string | number; // Or any, but string/number are most common for select values
label: string;
disabled?: boolean;
}
export default defineComponent({
name: 'VSelect',
props: {
modelValue: {
type: [String, Number] as PropType<string | number>,
required: true,
},
options: {
type: Array as PropType<SelectOption[]>,
required: true,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
required: {
type: Boolean,
default: false,
},
error: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
placeholder: {
type: String,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const selectClasses = computed(() => [
'form-input', // Re-use .form-input styles
'select', // Specific class for select styling (e.g., dropdown arrow)
{ 'error': props.error },
]);
const onChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
// Attempt to convert value to number if original option value was a number
// This helps v-model work more intuitively with numeric values
const selectedOption = props.options.find(opt => String(opt.value) === target.value);
let valueToEmit: string | number = target.value;
if (selectedOption && typeof selectedOption.value === 'number') {
valueToEmit = parseFloat(target.value);
}
emit('update:modelValue', valueToEmit);
};
return {
selectClasses,
onChange,
};
},
});
</script>
<style lang="scss" scoped>
// Assume .form-input styles are available (globally or imported)
// For brevity, these are not repeated here but were defined in VInput/VTextarea.
// If they are not globally available, they should be added or imported.
.form-input {
display: block;
width: 100%;
padding: 0.5em 0.75em; // Adjust padding for select, esp. right for arrow
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
color: #212529;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
&[disabled] {
background-color: #e9ecef;
opacity: 1;
cursor: not-allowed;
}
&.error {
border-color: #dc3545;
&:focus {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
}
}
.select {
// Select-specific styling
appearance: none; // Remove default system appearance
// Custom dropdown arrow (often a ::after pseudo-element or background image)
// Example using background SVG:
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 16px 12px;
padding-right: 2.5rem; // Ensure space for the arrow
// For placeholder option (disabled, selected)
&:invalid, option[value=""][disabled] {
color: #6c757d; // Placeholder text color
}
// Ensure that when a real value is selected, the color is the normal text color
& option {
color: #212529; // Or your default text color for options
}
// Fix for Firefox showing a lower opacity on disabled options
& option:disabled {
color: #adb5bd; // A lighter color for disabled options, but still readable
}
}
</style>

View 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);
});
});

View 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.' },
},
},
};

View 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>

View 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
});
});

View 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."}
}
}
};

View 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>

View File

@ -0,0 +1,117 @@
import { mount } from '@vue/test-utils';
import VTextarea from './VTextarea.vue';
import { describe, it, expect } from 'vitest';
describe('VTextarea.vue', () => {
it('binds modelValue and emits update:modelValue correctly (v-model)', async () => {
const wrapper = mount(VTextarea, {
props: { modelValue: 'initial content' },
});
const textareaElement = wrapper.find('textarea');
// Check initial value
expect(textareaElement.element.value).toBe('initial content');
// Simulate user input
await textareaElement.setValue('new content');
// Check emitted event
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['new content']);
// Check that prop update (simulating parent v-model update) changes the value
await wrapper.setProps({ modelValue: 'updated from parent' });
expect(textareaElement.element.value).toBe('updated from parent');
});
it('applies placeholder when provided', () => {
const placeholderText = 'Enter details...';
const wrapper = mount(VTextarea, {
props: { modelValue: '', placeholder: placeholderText },
});
expect(wrapper.find('textarea').attributes('placeholder')).toBe(placeholderText);
});
it('is disabled when disabled prop is true', () => {
const wrapper = mount(VTextarea, {
props: { modelValue: '', disabled: true },
});
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined();
});
it('is not disabled by default', () => {
const wrapper = mount(VTextarea, { props: { modelValue: '' } });
expect(wrapper.find('textarea').attributes('disabled')).toBeUndefined();
});
it('is required when required prop is true', () => {
const wrapper = mount(VTextarea, {
props: { modelValue: '', required: true },
});
expect(wrapper.find('textarea').attributes('required')).toBeDefined();
});
it('is not required by default', () => {
const wrapper = mount(VTextarea, { props: { modelValue: '' } });
expect(wrapper.find('textarea').attributes('required')).toBeUndefined();
});
it('sets the rows attribute correctly', () => {
const wrapper = mount(VTextarea, {
props: { modelValue: '', rows: 5 },
});
expect(wrapper.find('textarea').attributes('rows')).toBe('5');
});
it('defaults rows to 3 if not provided', () => {
const wrapper = mount(VTextarea, { props: { modelValue: '' } });
expect(wrapper.find('textarea').attributes('rows')).toBe('3');
});
it('applies error class and aria-invalid when error prop is true', () => {
const wrapper = mount(VTextarea, {
props: { modelValue: '', error: true },
});
const textarea = wrapper.find('textarea');
expect(textarea.classes()).toContain('form-input');
expect(textarea.classes()).toContain('textarea'); // Specific class
expect(textarea.classes()).toContain('error');
expect(textarea.attributes('aria-invalid')).toBe('true');
});
it('does not apply error class or aria-invalid by default or when error is false', () => {
const wrapperDefault = mount(VTextarea, { props: { modelValue: '' } });
const textareaDefault = wrapperDefault.find('textarea');
expect(textareaDefault.classes()).toContain('form-input');
expect(textareaDefault.classes()).toContain('textarea');
expect(textareaDefault.classes()).not.toContain('error');
expect(textareaDefault.attributes('aria-invalid')).toBeNull();
const wrapperFalse = mount(VTextarea, {
props: { modelValue: '', error: false },
});
const textareaFalse = wrapperFalse.find('textarea');
expect(textareaFalse.classes()).not.toContain('error');
expect(textareaFalse.attributes('aria-invalid')).toBeNull();
});
it('passes id prop to the textarea element', () => {
const textareaId = 'my-custom-textarea-id';
const wrapper = mount(VTextarea, {
props: { modelValue: '', id: textareaId },
});
expect(wrapper.find('textarea').attributes('id')).toBe(textareaId);
});
it('does not have an id attribute if id prop is not provided', () => {
const wrapper = mount(VTextarea, { props: { modelValue: '' } });
expect(wrapper.find('textarea').attributes('id')).toBeUndefined();
});
it('includes "textarea" class in addition to "form-input"', () => {
const wrapper = mount(VTextarea, { props: { modelValue: '' } });
expect(wrapper.find('textarea').classes()).toContain('textarea');
expect(wrapper.find('textarea').classes()).toContain('form-input');
});
});

View File

@ -0,0 +1,173 @@
import VTextarea from './VTextarea.vue';
import VFormField from './VFormField.vue'; // To demonstrate usage with VFormField
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue'; // For v-model in stories
const meta: Meta<typeof VTextarea> = {
title: 'Valerie/VTextarea',
component: VTextarea,
tags: ['autodocs'],
argTypes: {
modelValue: { control: 'text', description: 'Bound value using v-model.' },
placeholder: { control: 'text' },
disabled: { control: 'boolean' },
required: { control: 'boolean' },
rows: { control: 'number' },
error: { control: 'boolean', description: 'Applies error styling.' },
id: { control: 'text' },
// 'update:modelValue': { action: 'updated' }
},
parameters: {
docs: {
description: {
component: 'A textarea component for multi-line text input, supporting v-model, states, and customizable rows.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VTextarea>;
// Template for v-model interaction in stories (similar to VInput)
const VModelTemplate: Story = {
render: (args) => ({
components: { VTextarea },
setup() {
const storyValue = ref(args.modelValue || '');
const onInput = (newValue: string) => {
storyValue.value = newValue;
// context.emit('update:modelValue', newValue); // For SB actions
}
return { args, storyValue, onInput };
},
template: '<VTextarea v-bind="args" :modelValue="storyValue" @update:modelValue="onInput" />',
}),
};
export const Basic: Story = {
...VModelTemplate,
args: {
id: 'basicTextarea',
modelValue: 'This is some multi-line text.\nIt spans across multiple lines.',
},
};
export const WithPlaceholder: Story = {
...VModelTemplate,
args: {
id: 'placeholderTextarea',
placeholder: 'Enter your comments here...',
modelValue: '',
},
};
export const Disabled: Story = {
...VModelTemplate,
args: {
id: 'disabledTextarea',
modelValue: 'This content cannot be changed.',
disabled: true,
},
};
export const Required: Story = {
...VModelTemplate,
args: {
id: 'requiredTextarea',
modelValue: '',
required: true,
placeholder: 'This field is required',
},
parameters: {
docs: {
description: { story: 'The `required` attribute is set. Form submission behavior depends on the browser and form context.' },
},
},
};
export const ErrorState: Story = {
...VModelTemplate,
args: {
id: 'errorTextarea',
modelValue: 'This text has some issues.',
error: true,
},
};
export const CustomRows: Story = {
...VModelTemplate,
args: {
id: 'customRowsTextarea',
modelValue: 'This textarea has more rows.\nAllowing for more visible text.\nWithout scrolling initially.',
rows: 5,
},
};
export const FewerRows: Story = {
...VModelTemplate,
args: {
id: 'fewerRowsTextarea',
modelValue: 'Only two rows here.',
rows: 2,
},
};
// Story demonstrating VTextarea used within VFormField
export const InFormField: Story = {
render: (args) => ({
components: { VTextarea, VFormField },
setup() {
const storyValue = ref(args.textareaArgs.modelValue || '');
const onInput = (newValue: string) => {
storyValue.value = newValue;
}
return { args, storyValue, onInput };
},
template: `
<VFormField :label="args.formFieldArgs.label" :forId="args.textareaArgs.id" :errorMessage="args.formFieldArgs.errorMessage">
<VTextarea
v-bind="args.textareaArgs"
:modelValue="storyValue"
@update:modelValue="onInput"
/>
</VFormField>
`,
}),
args: {
formFieldArgs: {
label: 'Your Feedback',
errorMessage: '',
},
textareaArgs: {
id: 'feedbackField',
modelValue: '',
placeholder: 'Please provide your detailed feedback...',
rows: 4,
error: false,
},
},
parameters: {
docs: {
description: { story: '`VTextarea` used inside a `VFormField`. The `id` on `VTextarea` should match `forId` on `VFormField`.' },
},
},
};
export const InFormFieldWithError: Story = {
...InFormField, // Inherit render function
args: {
formFieldArgs: {
label: 'Description',
errorMessage: 'The description is too short.',
},
textareaArgs: {
id: 'descriptionFieldError',
modelValue: 'Too brief.',
placeholder: 'Provide a detailed description',
rows: 3,
error: true, // Set VTextarea's error state
},
},
};

View File

@ -0,0 +1,139 @@
<template>
<textarea
:id="id"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:rows="rows"
:class="textareaClasses"
:aria-invalid="error ? 'true' : null"
@input="onInput"
></textarea>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
export default defineComponent({
name: 'VTextarea',
props: {
modelValue: {
type: String,
required: true,
},
placeholder: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
required: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 3,
},
error: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const textareaClasses = computed(() => [
'form-input', // Re-use .form-input styles from VInput if they are generic enough
'textarea', // Specific class for textarea if needed for overrides or additions
{ 'error': props.error },
]);
const onInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement;
emit('update:modelValue', target.value);
};
return {
textareaClasses,
onInput,
};
},
});
</script>
<style lang="scss" scoped>
// Assuming .form-input is defined globally or imported, providing base styling.
// If VInput.vue's <style> is not scoped, .form-input might be available.
// If it is scoped, or you want VTextarea to be independent, redefine or import.
// For this example, let's assume .form-input styles from VInput might apply if global,
// or we can duplicate/abstract them.
// Minimal re-definition or import of .form-input (if not globally available)
// If VInput.scss is structured to be importable (e.g. using @use or if not scoped):
// @import 'VInput.scss'; // (path dependent) - this won't work directly with scoped SFC styles normally
// Let's add some basic .form-input like styles here for completeness,
// assuming they are not inherited or globally available from VInput.vue's styles.
// Ideally, these would be part of a shared SCSS utility file.
.form-input {
display: block;
width: 100%;
padding: 0.5em 0.75em;
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
color: #212529;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
&::placeholder {
color: #6c757d;
opacity: 1;
}
&[disabled],
&[readonly] { // readonly is not a prop here, but good for general form-input style
background-color: #e9ecef;
opacity: 1;
cursor: not-allowed;
}
&.error {
border-color: #dc3545;
&:focus {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
}
}
// Textarea specific styles
.textarea {
// Override line-height if needed, or ensure it works well with multi-line text.
// line-height: 1.5; // Usually inherited correctly from .form-input
// May add min-height or resize behavior if desired:
// resize: vertical; // Allow vertical resize, disable horizontal
min-height: calc(1.5em * var(--v-textarea-rows, 3) + 1em + 2px); // Approx based on rows, padding, border
}
// CSS variable for rows to potentially influence height if needed by .textarea class
// This is an alternative way to use props.rows in CSS if you need more complex calculations.
// For direct attribute binding like :rows="rows", this is not strictly necessary.
// :style="{ '--v-textarea-rows': rows }" could be bound to the textarea element.
</style>

View File

@ -0,0 +1,109 @@
import { mount } from '@vue/test-utils';
import VToggleSwitch from './VToggleSwitch.vue';
import { describe, it, expect } from 'vitest';
describe('VToggleSwitch.vue', () => {
it('binds modelValue and emits update:modelValue on change', async () => {
const wrapper = mount(VToggleSwitch, {
props: { modelValue: false, id: 'test-switch' }, // id is required due to default prop generation if not passed
});
const input = wrapper.find('input[type="checkbox"]');
// Initial state
expect(input.element.checked).toBe(false);
// Simulate change by setting checked state (how user interacts)
await input.setChecked(true);
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([true]);
// Simulate parent updating modelValue
await wrapper.setProps({ modelValue: true });
expect(input.element.checked).toBe(true);
await input.setChecked(false);
expect(wrapper.emitted()['update:modelValue'][1]).toEqual([false]);
});
it('applies disabled state to input and container class', () => {
const wrapper = mount(VToggleSwitch, {
props: { modelValue: false, disabled: true, id: 'disabled-switch' },
});
expect(wrapper.find('input[type="checkbox"]').attributes('disabled')).toBeDefined();
expect(wrapper.find('.switch-container').classes()).toContain('disabled');
});
it('is not disabled by default', () => {
const wrapper = mount(VToggleSwitch, { props: { modelValue: false, id: 'enabled-switch' } });
expect(wrapper.find('input[type="checkbox"]').attributes('disabled')).toBeUndefined();
expect(wrapper.find('.switch-container').classes()).not.toContain('disabled');
});
it('applies provided id or generates one automatically', () => {
const providedId = 'my-custom-id';
const wrapperWithId = mount(VToggleSwitch, {
props: { modelValue: false, id: providedId },
});
const inputWithId = wrapperWithId.find('input[type="checkbox"]');
expect(inputWithId.attributes('id')).toBe(providedId);
expect(wrapperWithId.find('label.switch').attributes('for')).toBe(providedId);
const wrapperWithoutId = mount(VToggleSwitch, { props: { modelValue: false } });
const inputWithoutId = wrapperWithoutId.find('input[type="checkbox"]');
const generatedId = inputWithoutId.attributes('id');
expect(generatedId).toMatch(/^v-toggle-switch-/);
expect(wrapperWithoutId.find('label.switch').attributes('for')).toBe(generatedId);
});
it('renders accessible label (sr-only) from prop or default', () => {
const labelText = 'Enable High Contrast Mode';
const wrapperWithLabel = mount(VToggleSwitch, {
props: { modelValue: false, label: labelText, id: 'label-switch' },
});
const srLabel1 = wrapperWithLabel.find('label.switch > span.sr-only');
expect(srLabel1.exists()).toBe(true);
expect(srLabel1.text()).toBe(labelText);
const wrapperDefaultLabel = mount(VToggleSwitch, { props: { modelValue: false, id: 'default-label-switch' } });
const srLabel2 = wrapperDefaultLabel.find('label.switch > span.sr-only');
expect(srLabel2.exists()).toBe(true);
expect(srLabel2.text()).toBe('Toggle Switch'); // Default label
});
it('input has role="switch"', () => {
const wrapper = mount(VToggleSwitch, { props: { modelValue: false, id: 'role-switch' } });
expect(wrapper.find('input[type="checkbox"]').attributes('role')).toBe('switch');
});
it('has .switch-container and label.switch classes', () => {
const wrapper = mount(VToggleSwitch, { props: { modelValue: false, id: 'class-switch' } });
expect(wrapper.find('.switch-container').exists()).toBe(true);
expect(wrapper.find('label.switch').exists()).toBe(true);
});
it('renders onText when modelValue is true and onText is provided', () => {
const wrapper = mount(VToggleSwitch, {
props: { modelValue: true, onText: 'ON', offText: 'OFF', id: 'on-text-switch' }
});
const onTextView = wrapper.find('.switch-text-on');
expect(onTextView.exists()).toBe(true);
expect(onTextView.text()).toBe('ON');
expect(wrapper.find('.switch-text-off').exists()).toBe(false);
});
it('renders offText when modelValue is false and offText is provided', () => {
const wrapper = mount(VToggleSwitch, {
props: { modelValue: false, onText: 'ON', offText: 'OFF', id: 'off-text-switch' }
});
const offTextView = wrapper.find('.switch-text-off');
expect(offTextView.exists()).toBe(true);
expect(offTextView.text()).toBe('OFF');
expect(wrapper.find('.switch-text-on').exists()).toBe(false);
});
it('does not render onText/offText if not provided', () => {
const wrapper = mount(VToggleSwitch, { props: { modelValue: false, id: 'no-text-switch' } });
expect(wrapper.find('.switch-text-on').exists()).toBe(false);
expect(wrapper.find('.switch-text-off').exists()).toBe(false);
});
});

View File

@ -0,0 +1,138 @@
import VToggleSwitch from './VToggleSwitch.vue';
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref, watch } from 'vue';
const meta: Meta<typeof VToggleSwitch> = {
title: 'Valerie/VToggleSwitch',
component: VToggleSwitch,
tags: ['autodocs'],
argTypes: {
modelValue: { control: 'boolean', description: 'State of the toggle (v-model).' },
disabled: { control: 'boolean' },
id: { control: 'text' },
label: { control: 'text', description: 'Accessible label (visually hidden).' },
onText: { control: 'text', description: 'Text for ON state (inside switch).' },
offText: { control: 'text', description: 'Text for OFF state (inside switch).' },
// Events
'update:modelValue': { action: 'update:modelValue', table: {disable: true} },
},
parameters: {
docs: {
description: {
component: 'A toggle switch component, often used for boolean settings. It uses a hidden checkbox input for accessibility and state management, and custom styling for appearance.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof VToggleSwitch>;
// Template for managing v-model in stories
const VModelTemplate: Story = {
render: (args) => ({
components: { VToggleSwitch },
setup() {
const switchState = ref(args.modelValue);
watch(() => args.modelValue, (newVal) => {
switchState.value = newVal;
});
const onUpdateModelValue = (val: boolean) => {
switchState.value = val;
// args.modelValue = val; // Update Storybook arg
};
return { ...args, switchState, onUpdateModelValue };
},
template: `
<div style="display: flex; align-items: center; gap: 10px;">
<VToggleSwitch
:modelValue="switchState"
@update:modelValue="onUpdateModelValue"
:disabled="disabled"
:id="id"
:label="label"
:onText="onText"
:offText="offText"
/>
<span>Current state: {{ switchState ? 'ON' : 'OFF' }}</span>
</div>
`,
}),
};
export const Basic: Story = {
...VModelTemplate,
args: {
modelValue: false,
id: 'basic-toggle',
label: 'Enable feature',
},
};
export const DefaultOn: Story = {
...VModelTemplate,
args: {
modelValue: true,
id: 'default-on-toggle',
label: 'Notifications enabled',
},
};
export const DisabledOff: Story = {
...VModelTemplate,
args: {
modelValue: false,
disabled: true,
id: 'disabled-off-toggle',
label: 'Feature disabled',
},
};
export const DisabledOn: Story = {
...VModelTemplate,
args: {
modelValue: true,
disabled: true,
id: 'disabled-on-toggle',
label: 'Setting locked on',
},
};
export const WithCustomIdAndLabel: Story = {
...VModelTemplate,
args: {
modelValue: false,
id: 'custom-id-for-toggle',
label: 'Subscribe to advanced updates',
},
};
export const WithOnOffText: Story = {
...VModelTemplate,
args: {
modelValue: true,
id: 'text-toggle',
label: 'Mode selection',
onText: 'ON',
offText: 'OFF',
},
parameters: {
docs: {
description: { story: 'Displays "ON" or "OFF" text within the switch. Note: This requires appropriate styling in `VToggleSwitch.vue` to position the text correctly and may need adjustments based on design specifics for text visibility and overlap with the thumb.' },
},
},
};
export const NoProvidedLabel: Story = {
...VModelTemplate,
args: {
modelValue: false,
id: 'no-label-toggle',
// label prop is not set, will use default 'Toggle Switch'
},
parameters: {
docs: {
description: { story: 'When `label` prop is not provided, it defaults to "Toggle Switch" for accessibility. This label is visually hidden but available to screen readers.' },
},
},
};

View File

@ -0,0 +1,184 @@
<template>
<div class="switch-container" :class="{ 'disabled': disabled }">
<input
type="checkbox"
role="switch"
:checked="modelValue"
:disabled="disabled"
:id="componentId"
@change="handleChange"
class="sr-only-input"
/>
<label :for="componentId" class="switch">
<span class="sr-only">{{ label || 'Toggle Switch' }}</span>
<!-- The visual track and thumb are typically created via ::before and ::after on .switch (the label) -->
<!-- Text like onText/offText could be positioned absolutely within .switch if design requires -->
<span v-if="onText && modelValue" class="switch-text switch-text-on">{{ onText }}</span>
<span v-if="offText && !modelValue" class="switch-text switch-text-off">{{ offText }}</span>
</label>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
id: {
type: String,
default: null,
},
label: { // For accessibility, visually hidden
type: String,
default: 'Toggle Switch', // Default accessible name if not provided
},
onText: { // Optional text for 'on' state
type: String,
default: null,
},
offText: { // Optional text for 'off' state
type: String,
default: null,
},
});
const emit = defineEmits(['update:modelValue']);
const componentId = computed(() => {
return props.id || `v-toggle-switch-${Math.random().toString(36).substring(2, 9)}`;
});
const handleChange = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.checked);
};
</script>
<style lang="scss" scoped>
// Base styles should align with valerie-ui.scss's .switch definition
// Assuming .sr-only is globally defined (position: absolute; width: 1px; height: 1px; ...)
// If not, it needs to be defined here or imported.
.sr-only-input { // Class for the actual input to be hidden
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.sr-only { // For the accessible label text inside the visual label
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.switch-container {
display: inline-flex; // Or block, depending on desired layout flow
align-items: center;
position: relative; // For positioning text if needed
&.disabled {
opacity: 0.7;
cursor: not-allowed;
.switch {
cursor: not-allowed;
}
}
}
.switch {
// These styles are from the provided valerie-ui.scss
// They create the visual appearance of the switch.
position: relative;
display: inline-block;
width: 36px; // var(--switch-width, 36px)
height: 20px; // var(--switch-height, 20px)
background-color: var(--switch-bg-off, #adb5bd); // Off state background
border-radius: 20px; // var(--switch-height, 20px) / 2 for pill shape
cursor: pointer;
transition: background-color 0.2s ease-in-out;
// The thumb (circle)
&::before {
content: "";
position: absolute;
top: 2px; // var(--switch-thumb-offset, 2px)
left: 2px; // var(--switch-thumb-offset, 2px)
width: 16px; // var(--switch-thumb-size, 16px) -> height - 2*offset
height: 16px; // var(--switch-thumb-size, 16px)
background-color: white;
border-radius: 50%;
transition: transform 0.2s ease-in-out;
}
}
// Styling based on the hidden input's state using sibling selector (+)
// This is a common and effective pattern for custom form controls.
.sr-only-input:checked + .switch {
background-color: var(--switch-bg-on, #007bff); // On state background (e.g., primary color)
}
.sr-only-input:checked + .switch::before {
transform: translateX(16px); // var(--switch-width) - var(--switch-thumb-size) - 2 * var(--switch-thumb-offset)
// 36px - 16px - 2*2px = 16px
}
// Focus state for accessibility (applied to the label acting as switch)
.sr-only-input:focus-visible + .switch {
outline: 2px solid var(--switch-focus-ring-color, #007bff);
outline-offset: 2px;
// Or use box-shadow for a softer focus ring:
// box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.35);
}
// Optional: Styles for onText/offText if they are part of the design
// This is a basic example; exact positioning would depend on desired look.
.switch-text {
position: absolute;
font-size: 0.7rem;
font-weight: 500;
top: 50%;
transform: translateY(-50%);
user-select: none;
color: white; // Assuming text is on the colored part of the switch
}
.switch-text-on {
// Example: position onText to the left of the thumb when 'on'
left: 6px;
// visibility: hidden; // Shown by input:checked + .switch .switch-text-on
}
.switch-text-off {
// Example: position offText to the right of the thumb when 'off'
right: 6px;
// visibility: visible;
}
// Show/hide text based on state
// This is a simple way; could also use v-if/v-else in template if preferred.
// .sr-only-input:checked + .switch .switch-text-on { visibility: visible; }
// .sr-only-input:not(:checked) + .switch .switch-text-off { visibility: visible; }
// .sr-only-input:checked + .switch .switch-text-off { visibility: hidden; }
// .sr-only-input:not(:checked) + .switch .switch-text-on { visibility: hidden; }
// The v-if in the template is more Vue-idiomatic and cleaner for this.
</style>

View 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.
});

View 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',
},
};

View 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>

View File

@ -0,0 +1,206 @@
import VTabs from './VTabs.vue';
import VTabList from './VTabList.vue';
import VTab from './VTab.vue';
import VTabPanels from './VTabPanels.vue';
import VTabPanel from './VTabPanel.vue';
import VButton from '../VButton.vue'; // For v-model interaction demo
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref, computed } from 'vue';
const meta: Meta<typeof VTabs> = {
title: 'Valerie/Tabs System',
component: VTabs,
subcomponents: { VTabList, VTab, VTabPanels, VTabPanel },
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'A flexible and accessible tabs system composed of `VTabs`, `VTabList`, `VTab`, `VTabPanels`, and `VTabPanel`. Use `v-model` on `VTabs` to control the active tab.',
},
},
},
argTypes: {
modelValue: { control: 'select', options: ['profile', 'settings', 'billing', null], description: 'ID of the active tab (for v-model).' },
initialTab: { control: 'select', options: ['profile', 'settings', 'billing', null], description: 'ID of the initially active tab.' },
}
};
export default meta;
type Story = StoryObj<typeof VTabs>;
export const BasicTabs: Story = {
render: (args) => ({
components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel },
setup() {
// For stories not directly testing v-model, manage active tab locally or use initialTab
const currentTab = ref(args.modelValue || args.initialTab || 'profile');
return { args, currentTab };
},
template: `
<VTabs v-model="currentTab">
<VTabList aria-label="User Account Tabs">
<VTab id="profile" title="Profile" />
<VTab id="settings" title="Settings" />
<VTab id="billing" title="Billing" />
<VTab id="disabled" title="Disabled" :disabled="true" />
</VTabList>
<VTabPanels>
<VTabPanel id="profile">
<p><strong>Profile Tab Content:</strong> Information about the user.</p>
<input type="text" placeholder="User name" />
</VTabPanel>
<VTabPanel id="settings">
<p><strong>Settings Tab Content:</strong> Configuration options.</p>
<label><input type="checkbox" /> Enable notifications</label>
</VTabPanel>
<VTabPanel id="billing">
<p><strong>Billing Tab Content:</strong> Payment methods and history.</p>
<VButton variant="primary">Add Payment Method</VButton>
</VTabPanel>
<VTabPanel id="disabled">
<p>This panel should not be reachable if the tab is truly disabled.</p>
</VTabPanel>
</VTabPanels>
</VTabs>
`,
}),
args: {
// modelValue: 'profile', // Let VTabs default logic or initialTab handle it
initialTab: 'settings',
},
};
export const WithCustomTabContent: Story = {
render: (args) => ({
components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel, VIcon: meta.subcomponents.VIcon }, // Assuming VIcon for demo
setup() {
const currentTab = ref(args.initialTab || 'alerts');
return { args, currentTab };
},
template: `
<VTabs v-model="currentTab">
<VTabList aria-label="Notification Tabs">
<VTab id="alerts">
<!-- Using VIcon as an example, ensure it's available or replace -->
<span style="color: red; margin-right: 4px;">🔔</span> Alerts
</VTab>
<VTab id="messages">
Messages <span style="background: #007bff; color: white; border-radius: 10px; padding: 2px 6px; font-size: 0.8em; margin-left: 5px;">3</span>
</VTab>
</VTabList>
<VTabPanels>
<VTabPanel id="alerts">
<p>Content for Alerts. Example of custom content in VTab.</p>
</VTabPanel>
<VTabPanel id="messages">
<p>Content for Messages. Also has custom content in its VTab.</p>
<ul>
<li>Message 1</li>
<li>Message 2</li>
<li>Message 3</li>
</ul>
</VTabPanel>
</VTabPanels>
</VTabs>
`,
}),
args: {
initialTab: 'alerts',
}
};
export const VModelInteraction: Story = {
render: (args) => ({
components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel, VButton },
setup() {
// This story uses args.modelValue directly, which Storybook controls can manipulate.
// Vue's v-model on the component will work with Storybook's arg system.
const currentTab = ref(args.modelValue || 'first');
watch(() => args.modelValue, (newVal) => { // Keep local ref in sync if arg changes externally
if (newVal !== undefined && newVal !== null) currentTab.value = newVal;
});
const availableTabs = ['first', 'second', 'third'];
const selectNextTab = () => {
const currentIndex = availableTabs.indexOf(currentTab.value);
const nextIndex = (currentIndex + 1) % availableTabs.length;
currentTab.value = availableTabs[nextIndex];
// args.modelValue = availableTabs[nextIndex]; // Update arg for SB control
};
return { args, currentTab, selectNextTab, availableTabs };
},
template: `
<div>
<VTabs v-model="currentTab">
<VTabList aria-label="Interactive Tabs">
<VTab v-for="tabId in availableTabs" :key="tabId" :id="tabId" :title="tabId.charAt(0).toUpperCase() + tabId.slice(1) + ' Tab'" />
</VTabList>
<VTabPanels>
<VTabPanel v-for="tabId in availableTabs" :key="tabId" :id="tabId">
<p>Content for <strong>{{ tabId }}</strong> tab.</p>
</VTabPanel>
</VTabPanels>
</VTabs>
<div style="margin-top: 20px;">
<p>Current active tab (v-model): {{ currentTab || 'None' }}</p>
<VButton @click="selectNextTab">Select Next Tab Programmatically</VButton>
<p><em>Note: Storybook control for 'modelValue' can also change the tab.</em></p>
</div>
</div>
`,
}),
args: {
modelValue: 'first', // Initial value for the v-model
},
};
export const EmptyTabs: Story = {
render: (args) => ({
components: { VTabs, VTabList, VTabPanels },
setup() { return { args }; },
template: `
<VTabs :modelValue="args.modelValue">
<VTabList aria-label="Empty Tab List"></VTabList>
<VTabPanels>
<!-- No VTabPanel components -->
</VTabPanels>
</VTabs>
`,
}),
args: {
modelValue: null,
},
parameters: {
docs: {
description: { story: 'Demonstrates the Tabs system with no tabs or panels defined. The `VTabs` onMounted logic should handle this gracefully (no default tab selected).' },
},
},
};
export const TabsWithOnlyOnePanel: Story = {
render: (args) => ({
components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel },
setup() {
const currentTab = ref(args.modelValue || 'single');
return { args, currentTab };
},
template: `
<VTabs v-model="currentTab">
<VTabList aria-label="Single Tab Example">
<VTab id="single" title="The Only Tab" />
</VTabList>
<VTabPanels>
<VTabPanel id="single">
<p>This is the content of the only tab panel.</p>
<p>The `VTabs` `onMounted` logic should select this tab by default if no other tab is specified via `modelValue` or `initialTab`.</p>
</VTabPanel>
</VTabPanels>
</VTabs>
`,
}),
args: {
// modelValue: 'single', // Let default selection logic work
},
};

View File

@ -0,0 +1,108 @@
import { mount } from '@vue/test-utils';
import VTab from './VTab.vue';
import { TabsProviderKey } from './types';
import { ref } from 'vue';
import type { TabId, TabsContext } from './types';
// Mock a VTabs provider for VTab tests
const mockTabsContext = (activeTabIdValue: TabId | null = 'test1'): TabsContext => ({
activeTabId: ref(activeTabIdValue),
selectTab: vi.fn(),
});
describe('VTab.vue', () => {
it('renders title prop when no default slot', () => {
const wrapper = mount(VTab, {
props: { id: 'tab1', title: 'My Tab Title' },
global: { provide: { [TabsProviderKey as any]: mockTabsContext() } },
});
expect(wrapper.text()).toBe('My Tab Title');
});
it('renders default slot content instead of title prop', () => {
const slotContent = '<i>Custom Tab Content</i>';
const wrapper = mount(VTab, {
props: { id: 'tab1', title: 'Ignored Title' },
slots: { default: slotContent },
global: { provide: { [TabsProviderKey as any]: mockTabsContext() } },
});
expect(wrapper.html()).toContain(slotContent);
expect(wrapper.text()).not.toBe('Ignored Title');
});
it('computes isActive correctly', () => {
const context = mockTabsContext('activeTab');
const wrapper = mount(VTab, {
props: { id: 'activeTab' },
global: { provide: { [TabsProviderKey as any]: context } },
});
expect(wrapper.vm.isActive).toBe(true);
expect(wrapper.classes()).toContain('active');
expect(wrapper.attributes('aria-selected')).toBe('true');
const wrapperInactive = mount(VTab, {
props: { id: 'inactiveTab' },
global: { provide: { [TabsProviderKey as any]: context } },
});
expect(wrapperInactive.vm.isActive).toBe(false);
expect(wrapperInactive.classes()).not.toContain('active');
expect(wrapperInactive.attributes('aria-selected')).toBe('false');
});
it('calls selectTab with its id on click if not disabled', async () => {
const context = mockTabsContext('anotherTab');
const wrapper = mount(VTab, {
props: { id: 'clickableTab', title: 'Click Me' },
global: { provide: { [TabsProviderKey as any]: context } },
});
await wrapper.trigger('click');
expect(context.selectTab).toHaveBeenCalledWith('clickableTab');
});
it('does not call selectTab on click if disabled', async () => {
const context = mockTabsContext();
const wrapper = mount(VTab, {
props: { id: 'disabledTab', title: 'Disabled', disabled: true },
global: { provide: { [TabsProviderKey as any]: context } },
});
await wrapper.trigger('click');
expect(context.selectTab).not.toHaveBeenCalled();
});
it('applies disabled attribute and class when disabled prop is true', () => {
const wrapper = mount(VTab, {
props: { id: 'tab1', disabled: true },
global: { provide: { [TabsProviderKey as any]: mockTabsContext() } },
});
expect(wrapper.attributes('disabled')).toBeDefined();
expect(wrapper.classes()).toContain('disabled');
});
it('sets ARIA attributes correctly', () => {
const wrapper = mount(VTab, {
props: { id: 'contactTab' },
global: { provide: { [TabsProviderKey as any]: mockTabsContext('contactTab') } },
});
expect(wrapper.attributes('role')).toBe('tab');
expect(wrapper.attributes('id')).toBe('tab-contactTab');
expect(wrapper.attributes('aria-controls')).toBe('panel-contactTab');
expect(wrapper.attributes('aria-selected')).toBe('true');
expect(wrapper.attributes('tabindex')).toBe('0'); // Active tab should be tabbable
});
it('sets tabindex to -1 for inactive tabs', () => {
const wrapper = mount(VTab, {
props: { id: 'inactiveContactTab' },
global: { provide: { [TabsProviderKey as any]: mockTabsContext('activeTab') } }, // Different active tab
});
expect(wrapper.attributes('tabindex')).toBe('-1');
});
it('throws error if not used within VTabs (no context provided)', () => {
// Prevent console error from Vue about missing provide
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(()_ => {});
expect(() => mount(VTab, { props: { id: 'tab1' } })).toThrow('VTab must be used within a VTabs component.');
consoleErrorSpy.mockRestore();
});
});

View File

@ -0,0 +1,99 @@
<template>
<button
role="tab"
:id="'tab-' + id"
:aria-selected="isActive.toString()"
:aria-controls="ariaControls"
:disabled="disabled"
:tabindex="isActive ? 0 : -1"
@click="handleClick"
class="tab-item"
:class="{ 'active': isActive, 'disabled': disabled }"
>
<slot>{{ title }}</slot>
</button>
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import type { TabId, TabsContext } from './types';
import { TabsProviderKey } from './types';
const props = defineProps<{
id: TabId;
title?: string;
disabled?: boolean;
}>();
defineOptions({
name: 'VTab',
});
const tabsContext = inject<TabsContext>(TabsProviderKey);
if (!tabsContext) {
throw new Error('VTab must be used within a VTabs component.');
}
const { activeTabId, selectTab } = tabsContext;
const isActive = computed(() => activeTabId.value === props.id);
const ariaControls = computed(() => `panel-${props.id}`);
const handleClick = () => {
if (!props.disabled) {
selectTab(props.id);
}
};
</script>
<style lang="scss" scoped>
.tab-item {
padding: 0.75rem 1.25rem; // Example padding
margin-bottom: -1px; // Overlap with tab-list border for active state
border: 1px solid transparent;
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
cursor: pointer;
background-color: transparent;
color: #007bff; // Default tab text color (link-like)
font-size: 1rem;
font-weight: 500;
text-align: center;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
&:hover:not(.disabled):not(.active) {
border-color: #e9ecef #e9ecef #dee2e6; // Light border on hover
background-color: #f8f9fa; // Light background on hover
color: #0056b3;
}
&.active { // Or use [aria-selected="true"]
color: #495057; // Active tab text color
background-color: #fff; // Active tab background (same as panel usually)
border-color: #dee2e6 #dee2e6 #fff; // Border connects with panel, bottom border transparent
}
&.disabled { // Or use [disabled]
color: #6c757d; // Disabled text color
cursor: not-allowed;
background-color: transparent;
border-color: transparent;
}
&:focus {
outline: none; // Remove default outline
// Add custom focus style if needed, e.g., box-shadow
// For accessibility, ensure focus is visible.
// box-shadow: 0 0 0 0.1rem rgba(0, 123, 255, 0.25); // Example focus ring
}
// Better focus visibility, especially for keyboard navigation
&:focus-visible:not(.disabled) {
outline: 2px solid #007bff; // Standard outline
outline-offset: 2px;
// Or use box-shadow for a softer focus ring:
// box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.35);
}
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div role="tablist" class="tab-list">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
// No specific script logic for VTabList, it's a simple layout component.
// Name is set for component identification in Vue Devtools and VTabs onMounted logic.
defineOptions({
name: 'VTabList',
});
</script>
<style lang="scss" scoped>
.tab-list {
display: flex;
// Example styling:
border-bottom: 1px solid #dee2e6; // Standard Bootstrap-like border
margin-bottom: 0; // Remove any default margin if needed
// Prevent scrolling if tabs overflow, or add scroll styling
// overflow-x: auto;
// white-space: nowrap;
}
</style>

View File

@ -0,0 +1,35 @@
import { mount } from '@vue/test-utils';
import VTabList from './VTabList.vue';
import VTabPanels from './VTabPanels.vue';
import { describe, it, expect } from 'vitest';
describe('VTabList.vue', () => {
it('renders default slot content', () => {
const slotContent = '<button>Tab 1</button><button>Tab 2</button>';
const wrapper = mount(VTabList, {
slots: { default: slotContent },
});
expect(wrapper.html()).toContain(slotContent);
});
it('has role="tablist" and class .tab-list', () => {
const wrapper = mount(VTabList);
expect(wrapper.attributes('role')).toBe('tablist');
expect(wrapper.classes()).toContain('tab-list');
});
});
describe('VTabPanels.vue', () => {
it('renders default slot content', () => {
const slotContent = '<div>Panel 1 Content</div><div>Panel 2 Content</div>';
const wrapper = mount(VTabPanels, {
slots: { default: slotContent },
});
expect(wrapper.html()).toContain(slotContent);
});
it('has class .tab-panels-container', () => {
const wrapper = mount(VTabPanels);
expect(wrapper.classes()).toContain('tab-panels-container');
});
});

View File

@ -0,0 +1,72 @@
import { mount } from '@vue/test-utils';
import VTabPanel from './VTabPanel.vue';
import { TabsProviderKey } from './types';
import { ref } from 'vue';
import type { TabId, TabsContext } from './types';
// Mock a VTabs provider for VTabPanel tests
const mockTabsContext = (activeTabIdValue: TabId | null = 'panelTest1'): TabsContext => ({
activeTabId: ref(activeTabIdValue),
selectTab: vi.fn(), // Not used by VTabPanel, but part of context
});
describe('VTabPanel.vue', () => {
it('renders default slot content', () => {
const panelContent = '<div>Panel Content Here</div>';
const wrapper = mount(VTabPanel, {
props: { id: 'panel1' },
slots: { default: panelContent },
global: { provide: { [TabsProviderKey as any]: mockTabsContext('panel1') } },
});
expect(wrapper.html()).toContain(panelContent);
});
it('is visible when its id matches activeTabId (isActive is true)', () => {
const wrapper = mount(VTabPanel, {
props: { id: 'activePanel' },
global: { provide: { [TabsProviderKey as any]: mockTabsContext('activePanel') } },
});
// v-show means the element is still rendered, but display: none is applied by Vue if false
expect(wrapper.vm.isActive).toBe(true);
expect(wrapper.element.style.display).not.toBe('none');
});
it('is hidden (display: none) when its id does not match activeTabId (isActive is false)', () => {
const wrapper = mount(VTabPanel, {
props: { id: 'inactivePanel' },
global: { provide: { [TabsProviderKey as any]: mockTabsContext('someOtherActivePanel') } },
});
expect(wrapper.vm.isActive).toBe(false);
// Vue applies display: none for v-show="false"
// Note: this might be an internal detail of Vue's v-show.
// A more robust test might be to check `wrapper.isVisible()` if available and configured.
// For now, checking the style attribute is a common way.
expect(wrapper.element.style.display).toBe('none');
});
it('sets ARIA attributes correctly', () => {
const panelId = 'infoPanel';
const wrapper = mount(VTabPanel, {
props: { id: panelId },
global: { provide: { [TabsProviderKey as any]: mockTabsContext(panelId) } },
});
expect(wrapper.attributes('role')).toBe('tabpanel');
expect(wrapper.attributes('id')).toBe(`panel-${panelId}`);
expect(wrapper.attributes('aria-labelledby')).toBe(`tab-${panelId}`);
expect(wrapper.attributes('tabindex')).toBe('0'); // Panel should be focusable
});
it('applies .tab-content class', () => {
const wrapper = mount(VTabPanel, {
props: { id: 'anyPanel' },
global: { provide: { [TabsProviderKey as any]: mockTabsContext('anyPanel') } },
});
expect(wrapper.classes()).toContain('tab-content');
});
it('throws error if not used within VTabs (no context provided)', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(()_ => {});
expect(() => mount(VTabPanel, { props: { id: 'panel1' } })).toThrow('VTabPanel must be used within a VTabs component.');
consoleErrorSpy.mockRestore();
});
});

View File

@ -0,0 +1,55 @@
<template>
<div
v-show="isActive"
role="tabpanel"
:id="'panel-' + id"
:aria-labelledby="ariaLabelledBy"
class="tab-content"
tabindex="0"
>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import type { TabId, TabsContext } from './types';
import { TabsProviderKey } from './types';
const props = defineProps<{
id: TabId;
}>();
defineOptions({
name: 'VTabPanel',
});
const tabsContext = inject<TabsContext>(TabsProviderKey);
if (!tabsContext) {
throw new Error('VTabPanel must be used within a VTabs component.');
}
const { activeTabId } = tabsContext;
const isActive = computed(() => activeTabId.value === props.id);
const ariaLabelledBy = computed(() => `tab-${props.id}`);
</script>
<style lang="scss" scoped>
.tab-content {
padding: 1.25rem; // Example padding, adjust as needed
// border: 1px solid #dee2e6; // If panels container doesn't have a border
// border-top: none;
background-color: #fff; // Ensure background for content
&:focus-visible { // For when panel itself is focused (e.g. after tab selection)
outline: 2px solid #007bff;
outline-offset: 2px;
// box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.35);
}
// Add styling for content within the panel if common patterns emerge
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div class="tab-panels-container">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
// No specific script logic for VTabPanels, it's a simple layout component.
// Name is set for component identification in Vue Devtools.
defineOptions({
name: 'VTabPanels',
});
</script>
<style lang="scss" scoped>
.tab-panels-container {
// This container wraps all VTabPanel components.
// It might have padding or other layout styles if needed.
// For example, if VTabPanel components don't have their own padding:
// padding: 1rem;
// border: 1px solid #dee2e6; // Example border matching tab-list
// border-top: none; // Avoid double border if tab-list has bottom border
// border-radius: 0 0 0.25rem 0.25rem; // Match overall tabs radius if any
}
</style>

View File

@ -0,0 +1,135 @@
import { mount } from '@vue/test-utils';
import VTabs from './VTabs.vue';
import VTab from './VTab.vue';
import VTabList from './VTabList.vue';
import VTabPanel from './VTabPanel.vue';
import VTabPanels from './VTabPanels.vue';
import { TabsProviderKey } from './types';
import { nextTick, h } from 'vue';
// Helper to create a minimal tabs structure for testing VTabs logic
const createBasicTabsStructure = (activeTabId: string | null = 'tab1') => {
return {
components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel },
template: `
<VTabs :modelValue="currentModelValue" @update:modelValue="val => currentModelValue = val" :initialTab="initialTabValue">
<VTabList>
<VTab id="tab1" title="Tab 1" />
<VTab id="tab2" title="Tab 2" />
</VTabList>
<VTabPanels>
<VTabPanel id="tab1"><p>Content 1</p></VTabPanel>
<VTabPanel id="tab2"><p>Content 2</p></VTabPanel>
</VTabPanels>
</VTabs>
`,
data() {
return {
currentModelValue: activeTabId,
initialTabValue: activeTabId, // Can be overridden in test
};
},
};
};
describe('VTabs.vue', () => {
it('initializes activeTabId with modelValue', () => {
const wrapper = mount(VTabs, {
props: { modelValue: 'second' },
slots: { default: '<VTabList><VTab id="first"/><VTab id="second"/></VTabList><VTabPanels><VTabPanel id="first"/><VTabPanel id="second"/></VTabPanels>' },
global: { components: { VTabList, VTab, VTabPanels, VTabPanel } } // Stubbing children
});
const context = wrapper.vm.$.provides[TabsProviderKey as any];
expect(context.activeTabId.value).toBe('second');
});
it('initializes activeTabId with initialTab if modelValue is not provided', () => {
const wrapper = mount(VTabs, {
props: { initialTab: 'third' },
slots: { default: '<VTabList><VTab id="first"/><VTab id="third"/></VTabList><VTabPanels><VTabPanel id="first"/><VTabPanel id="third"/></VTabPanels>' },
global: { components: { VTabList, VTab, VTabPanels, VTabPanel } }
});
const context = wrapper.vm.$.provides[TabsProviderKey as any];
expect(context.activeTabId.value).toBe('third');
});
it('updates activeTabId when modelValue prop changes', async () => {
const wrapper = mount(VTabs, {
props: { modelValue: 'one' },
slots: { default: '<VTabList><VTab id="one"/><VTab id="two"/></VTabList><VTabPanels><VTabPanel id="one"/><VTabPanel id="two"/></VTabPanels>' },
global: { components: { VTabList, VTab, VTabPanels, VTabPanel } }
});
const context = wrapper.vm.$.provides[TabsProviderKey as any];
expect(context.activeTabId.value).toBe('one');
await wrapper.setProps({ modelValue: 'two' });
expect(context.activeTabId.value).toBe('two');
});
it('emits update:modelValue when selectTab is called', async () => {
const wrapper = mount(VTabs, {
props: { modelValue: 'alpha' },
slots: { default: '<VTabList><VTab id="alpha"/><VTab id="beta"/></VTabList><VTabPanels><VTabPanel id="alpha"/><VTabPanel id="beta"/></VTabPanels>' },
global: { components: { VTabList, VTab, VTabPanels, VTabPanel } }
});
const context = wrapper.vm.$.provides[TabsProviderKey as any];
context.selectTab('beta');
await nextTick();
expect(wrapper.emitted()['update:modelValue']).toBeTruthy();
expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['beta']);
expect(context.activeTabId.value).toBe('beta');
});
it('selects the first tab if no modelValue or initialTab is provided on mount', async () => {
// This test is more involved as it requires inspecting slot children
// We need to ensure VTab components are actually rendered within the slots
const TestComponent = {
components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel },
template: `
<VTabs>
<VTabList>
<VTab id="firstMounted" title="First" />
<VTab id="secondMounted" title="Second" />
</VTabList>
<VTabPanels>
<VTabPanel id="firstMounted">Content First</VTabPanel>
<VTabPanel id="secondMounted">Content Second</VTabPanel>
</VTabPanels>
</VTabs>
`,
};
const wrapper = mount(TestComponent);
await nextTick(); // Wait for onMounted hook in VTabs
// Access VTabs instance to check its internal activeTabId via provided context
const vTabsInstance = wrapper.findComponent(VTabs);
const context = vTabsInstance.vm.$.provides[TabsProviderKey as any];
expect(context.activeTabId.value).toBe('firstMounted');
});
it('does not change activeTabId if modelValue is explicitly null and no initialTab', async () => {
const TestComponent = {
components: { VTabs, VTabList, VTab, VTabPanels, VTabPanel },
template: `
<VTabs :modelValue="null">
<VTabList> <VTab id="t1" /> </VTabList>
<VTabPanels> <VTabPanel id="t1" /> </VTabPanels>
</VTabs>
`,
};
const wrapper = mount(TestComponent);
await nextTick();
const vTabsInstance = wrapper.findComponent(VTabs);
const context = vTabsInstance.vm.$.provides[TabsProviderKey as any];
expect(context.activeTabId.value).toBeNull(); // Should remain null, not default to first tab
});
it('renders its default slot content', () => {
const wrapper = mount(VTabs, {
slots: { default: '<div class="test-slot-content">Hello</div>' },
});
expect(wrapper.find('.test-slot-content').exists()).toBe(true);
expect(wrapper.text()).toContain('Hello');
});
});

View File

@ -0,0 +1,94 @@
<template>
<div class="tabs">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, provide, onMounted, getCurrentInstance, type VNode } from 'vue';
import type { TabId, TabsContext } from './types';
import { TabsProviderKey } from './types';
const props = defineProps<{
modelValue?: TabId | null;
initialTab?: TabId | null;
}>();
const emit = defineEmits(['update:modelValue']);
const activeTabId = ref<TabId | null>(props.modelValue ?? props.initialTab ?? null);
watch(() => props.modelValue, (newVal) => {
if (newVal !== undefined && newVal !== null) {
activeTabId.value = newVal;
}
});
const selectTab = (tabId: TabId) => {
activeTabId.value = tabId;
emit('update:modelValue', tabId);
};
provide<TabsContext>(TabsProviderKey, {
activeTabId,
selectTab,
});
// Determine initial tab if not set by props
onMounted(() => {
if (activeTabId.value === null) {
// Try to find the first VTab's ID from slots
// This is a bit more complex due to Vue's slot structure
const instance = getCurrentInstance();
if (instance && instance.slots.default) {
const defaultSlots = instance.slots.default();
let firstTabId: TabId | null = null;
const findFirstTabRecursive = (nodes: VNode[]) => {
for (const node of nodes) {
if (firstTabId) break;
// Check if node is VTabList
if (node.type && (node.type as any).name === 'VTabList') {
if (node.children && Array.isArray(node.children)) {
// Children of VTabList could be VTab components directly or wrapped
for (const childNode of node.children as VNode[]) {
if (childNode.type && (childNode.type as any).name === 'VTab') {
if (childNode.props?.id) {
firstTabId = childNode.props.id;
break;
}
} else if (Array.isArray(childNode.children)) { // Handle cases where VTabs are nested in fragments or other elements
findFirstTabRecursive(childNode.children as VNode[]);
if (firstTabId) break;
}
}
} else if (typeof node.children === 'object' && node.children && 'default' in node.children) {
// If VTabList has its own default slot (e.g. from a render function)
// This part might need adjustment based on how VTabList is structured
}
} else if (node.children && Array.isArray(node.children)) {
findFirstTabRecursive(node.children as VNode[]);
}
}
};
findFirstTabRecursive(defaultSlots);
if (firstTabId) {
selectTab(firstTabId);
}
}
}
});
</script>
<style lang="scss" scoped>
.tabs {
// Basic container styling for the entire tabs system
// border: 1px solid #ccc; // Example border
// border-radius: 4px;
// overflow: hidden;
display: flex;
flex-direction: column; // Stack tablist and tabpanels
}
</style>

View File

@ -0,0 +1,10 @@
import type { Ref, InjectionKey } from 'vue';
export type TabId = string | number;
export interface TabsContext {
activeTabId: Ref<TabId | null>;
selectTab: (id: TabId) => void;
}
export const TabsProviderKey: InjectionKey<TabsContext> = Symbol('VTabs');

View File

@ -1,116 +1,84 @@
<template>
<main class="container page-padding">
<h1 class="mb-3">Account Settings</h1>
<VHeading level="1" text="Account Settings" class="mb-3" />
<div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading profile...</p>
<VSpinner label="Loading profile..." />
</div>
<div v-else-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="fetchProfile">Retry</button>
</div>
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchProfile">Retry</VButton>
</template>
</VAlert>
<form v-else @submit.prevent="onSubmitProfile">
<!-- Profile Section -->
<div class="card mb-3">
<div class="card-header">
<h3>Profile Information</h3>
</div>
<div class="card-body">
<VCard class="mb-3">
<template #header><VHeading level="3">Profile Information</VHeading></template>
<div class="flex flex-wrap" style="gap: 1rem;">
<div class="form-group flex-grow">
<label for="profileName" class="form-label">Name</label>
<input type="text" id="profileName" v-model="profile.name" class="form-input" required />
</div>
<div class="form-group flex-grow">
<label for="profileEmail" class="form-label">Email</label>
<input type="email" id="profileEmail" v-model="profile.email" class="form-input" required readonly />
</div>
</div>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Save Changes
</button>
</div>
<VFormField label="Name" class="flex-grow">
<VInput id="profileName" v-model="profile.name" required />
</VFormField>
<VFormField label="Email" class="flex-grow">
<VInput type="email" id="profileEmail" v-model="profile.email" required readonly />
</VFormField>
</div>
<template #footer>
<VButton type="submit" variant="primary" :disabled="saving">
<VSpinner v-if="saving" size="sm" /> Save Changes
</VButton>
</template>
</VCard>
</form>
<!-- Password Section -->
<form @submit.prevent="onChangePassword">
<div class="card mb-3">
<div class="card-header">
<h3>Change Password</h3>
</div>
<div class="card-body">
<VCard class="mb-3">
<template #header><VHeading level="3">Change Password</VHeading></template>
<div class="flex flex-wrap" style="gap: 1rem;">
<div class="form-group flex-grow">
<label for="currentPassword" class="form-label">Current Password</label>
<input type="password" id="currentPassword" v-model="password.current" class="form-input" required />
</div>
<div class="form-group flex-grow">
<label for="newPassword" class="form-label">New Password</label>
<input type="password" id="newPassword" v-model="password.newPassword" class="form-input" required />
</div>
</div>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary" :disabled="changingPassword">
<span v-if="changingPassword" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Change Password
</button>
</div>
<VFormField label="Current Password" class="flex-grow">
<VInput type="password" id="currentPassword" v-model="password.current" required />
</VFormField>
<VFormField label="New Password" class="flex-grow">
<VInput type="password" id="newPassword" v-model="password.newPassword" required />
</VFormField>
</div>
<template #footer>
<VButton type="submit" variant="primary" :disabled="changingPassword">
<VSpinner v-if="changingPassword" size="sm" /> Change Password
</VButton>
</template>
</VCard>
</form>
<!-- Notifications Section -->
<div class="card">
<div class="card-header">
<h3>Notification Preferences</h3>
</div>
<div class="card-body">
<ul class="item-list preference-list">
<li class="preference-item">
<VCard>
<template #header><VHeading level="3">Notification Preferences</VHeading></template>
<VList class="preference-list">
<VListItem class="preference-item">
<div class="preference-label">
<span>Email Notifications</span>
<small>Receive email notifications for important updates</small>
</div>
<label class="switch-container">
<input type="checkbox" v-model="preferences.emailNotifications" @change="onPreferenceChange" />
<span class="switch" aria-hidden="true"></span>
</label>
</li>
<li class="preference-item">
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange" label="Email Notifications" id="emailNotificationsToggle" />
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>List Updates</span>
<small>Get notified when lists are updated</small>
</div>
<label class="switch-container">
<input type="checkbox" v-model="preferences.listUpdates" @change="onPreferenceChange" />
<span class="switch" aria-hidden="true"></span>
</label>
</li>
<li class="preference-item">
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange" label="List Updates" id="listUpdatesToggle"/>
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>Group Activities</span>
<small>Receive notifications for group activities</small>
</div>
<label class="switch-container">
<input type="checkbox" v-model="preferences.groupActivities" @change="onPreferenceChange" />
<span class="switch" aria-hidden="true"></span>
</label>
</li>
</ul>
</div>
</div>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange" label="Group Activities" id="groupActivitiesToggle"/>
</VListItem>
</VList>
</VCard>
</main>
</template>
@ -118,6 +86,16 @@
import { ref, onMounted } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
import VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VInput from '@/components/valerie/VInput.vue';
import VButton from '@/components/valerie/VButton.vue';
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
interface Profile {
name: string;

View File

@ -1,82 +1,57 @@
<template>
<main class="container page-padding">
<div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading group details...</p>
</div>
<div v-else-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="fetchGroupDetails">Retry</button>
<VSpinner label="Loading group details..." />
</div>
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchGroupDetails">Retry</VButton>
</template>
</VAlert>
<div v-else-if="group">
<h1 class="mb-3">{{ group.name }}</h1>
<VHeading level="1" :text="group.name" class="mb-3" />
<div class="neo-grid">
<!-- Group Members Section -->
<div class="neo-card">
<div class="neo-card-header">
<h3>Group Members</h3>
</div>
<div class="neo-card-body">
<div v-if="group.members && group.members.length > 0" class="neo-members-list">
<div v-for="member in group.members" :key="member.id" class="neo-member-item">
<VCard>
<template #header><VHeading level="3">Group Members</VHeading></template>
<VList v-if="group.members && group.members.length > 0">
<VListItem v-for="member in group.members" :key="member.id" class="flex justify-between items-center">
<div class="neo-member-info">
<span class="neo-member-name">{{ member.email }}</span>
<span class="neo-member-role" :class="member.role?.toLowerCase()">{{ member.role || 'Member' }}</span>
<VBadge :text="member.role || 'Member'" :variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
</div>
<button v-if="canRemoveMember(member)" class="btn btn-danger btn-sm" @click="removeMember(member.id)"
:disabled="removingMember === member.id">
<span v-if="removingMember === member.id" class="spinner-dots-sm"
role="status"><span /><span /><span /></span>
Remove
</button>
</div>
</div>
<div v-else class="neo-empty-state">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-users" />
</svg>
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" @click="removeMember(member.id)" :disabled="removingMember === member.id">
<VSpinner v-if="removingMember === member.id" size="sm"/> Remove
</VButton>
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="users" size="lg" class="opacity-50 mb-2" />
<p>No members found.</p>
</div>
</div>
</div>
</VCard>
<!-- Invite Members Section -->
<div class="neo-card">
<div class="neo-card-header">
<h3>Invite Members</h3>
</div>
<div class="neo-card-body">
<button class="btn btn-primary w-full" @click="generateInviteCode" :disabled="generatingInvite">
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
{{ inviteCode ? 'Regenerate Invite Code' : 'Generate Invite Code' }}
</button>
<VCard>
<template #header><VHeading level="3">Invite Members</VHeading></template>
<VButton variant="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
<VSpinner v-if="generatingInvite" size="sm"/> {{ inviteCode ? 'Regenerate Invite Code' : 'Generate Invite Code' }}
</VButton>
<div v-if="inviteCode" class="neo-invite-code mt-3">
<label for="inviteCodeInput" class="neo-label">Current Active Invite Code:</label>
<div class="neo-input-group">
<input id="inviteCodeInput" type="text" :value="inviteCode" class="neo-input" readonly />
<button class="btn btn-neutral btn-icon-only" @click="copyInviteCodeHandler"
aria-label="Copy invite code">
<svg class="icon">
<use xlink:href="#icon-clipboard"></use>
</svg>
</button>
<VFormField label="Current Active Invite Code:" :label-sr-only="false">
<div class="flex items-center gap-2">
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler" aria-label="Copy invite code" />
</div>
<p v-if="copySuccess" class="neo-success-text">Invite code copied to clipboard!</p>
</VFormField>
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">Invite code copied to clipboard!</p>
</div>
<div v-else class="neo-empty-state mt-3">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-link" />
</svg>
<div v-else class="text-center py-4 mt-3">
<VIcon name="link" size="lg" class="opacity-50 mb-2" />
<p>No active invite code. Click the button above to generate one.</p>
</div>
</div>
</div>
</VCard>
</div>
<!-- Lists Section -->
@ -85,78 +60,61 @@
</div>
<!-- Chores Section -->
<div class="mt-4">
<div class="neo-card">
<div class="neo-card-header">
<h3>Group Chores</h3>
<router-link :to="`/groups/${groupId}/chores`" class="btn btn-primary">
<span class="material-icons">cleaning_services</span>
Manage Chores
</router-link>
<VCard class="mt-4">
<template #header>
<div class="flex justify-between items-center w-full">
<VHeading level="3">Group Chores</VHeading>
<VButton :to="`/groups/${groupId}/chores`" variant="primary">
<span class="material-icons" style="margin-right: 0.25em;">cleaning_services</span> Manage Chores
</VButton>
</div>
<div class="neo-card-body">
<div v-if="upcomingChores.length > 0" class="neo-chores-list">
<div v-for="chore in upcomingChores" :key="chore.id" class="neo-chore-item">
</template>
<VList v-if="upcomingChores.length > 0">
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
<div class="neo-chore-info">
<span class="neo-chore-name">{{ chore.name }}</span>
<span class="neo-chore-due">Due: {{ formatDate(chore.next_due_date) }}</span>
</div>
<span class="neo-chip" :class="getFrequencyColor(chore.frequency)">
{{ formatFrequency(chore.frequency) }}
</span>
</div>
</div>
<div v-else class="neo-empty-state">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-cleaning_services" />
</svg>
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" /> {/* Assuming cleaning_services is a valid VIcon name or will be added */}
<p>No chores scheduled. Click "Manage Chores" to create some!</p>
</div>
</div>
</div>
</div>
</VCard>
<!-- Expenses Section -->
<div class="mt-4">
<div class="neo-card">
<div class="neo-card-header">
<h3>Group Expenses</h3>
<router-link :to="`/groups/${groupId}/expenses`" class="btn btn-primary">
<span class="material-icons">payments</span>
Manage Expenses
</router-link>
<VCard class="mt-4">
<template #header>
<div class="flex justify-between items-center w-full">
<VHeading level="3">Group Expenses</VHeading>
<VButton :to="`/groups/${groupId}/expenses`" variant="primary">
<span class="material-icons" style="margin-right: 0.25em;">payments</span> Manage Expenses
</VButton>
</div>
<div class="neo-card-body">
<div v-if="recentExpenses.length > 0" class="neo-expenses-list">
<div v-for="expense in recentExpenses" :key="expense.id" class="neo-expense-item">
</template>
<VList v-if="recentExpenses.length > 0">
<VListItem v-for="expense in recentExpenses" :key="expense.id" class="flex justify-between items-center">
<div class="neo-expense-info">
<span class="neo-expense-name">{{ expense.description }}</span>
<span class="neo-expense-date">{{ formatDate(expense.expense_date) }}</span>
</div>
<div class="neo-expense-details">
<span class="neo-expense-amount">{{ expense.currency }} {{ formatAmount(expense.total_amount)
}}</span>
<span class="neo-chip" :class="getSplitTypeColor(expense.split_type)">
{{ formatSplitType(expense.split_type) }}
</span>
<span class="neo-expense-amount">{{ expense.currency }} {{ formatAmount(expense.total_amount) }}</span>
<VBadge :text="formatSplitType(expense.split_type)" :variant="getSplitTypeBadgeVariant(expense.split_type)" />
</div>
</div>
</div>
<div v-else class="neo-empty-state">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-payments" />
</svg>
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="payments" size="lg" class="opacity-50 mb-2" /> {/* Assuming payments is a valid VIcon name or will be added */}
<p>No expenses recorded. Click "Manage Expenses" to add some!</p>
</div>
</div>
</div>
</div>
</VCard>
</div>
<div v-else class="alert alert-info" role="status">
<div class="alert-content">Group not found or an error occurred.</div>
</div>
<VAlert v-else type="info" message="Group not found or an error occurred." />
</main>
</template>
@ -171,6 +129,17 @@ import { choreService } from '../services/choreService'
import type { Chore, ChoreFrequency } from '../types/chore'
import { format } from 'date-fns'
import type { Expense } from '@/types/expense'
import VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
import VButton from '@/components/valerie/VButton.vue';
import VBadge from '@/components/valerie/VBadge.vue';
import VInput from '@/components/valerie/VInput.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VIcon from '@/components/valerie/VIcon.vue';
interface Group {
id: string | number;
@ -355,16 +324,16 @@ const formatFrequency = (frequency: ChoreFrequency) => {
return options[frequency] || frequency
}
const getFrequencyColor = (frequency: ChoreFrequency) => {
const colors: Record<ChoreFrequency, string> = {
one_time: 'grey',
daily: 'blue',
weekly: 'green',
monthly: 'purple',
custom: 'orange'
}
return colors[frequency]
}
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): string => {
const colorMap: Record<ChoreFrequency, string> = {
one_time: 'neutral',
daily: 'info',
weekly: 'success',
monthly: 'accent', // Using accent for purple as an example
custom: 'warning'
};
return colorMap[frequency] || 'secondary';
};
// Add new methods for expenses
const loadRecentExpenses = async () => {
@ -387,16 +356,16 @@ const formatSplitType = (type: string) => {
).join(' ')
}
const getSplitTypeColor = (type: string) => {
const colors: Record<string, string> = {
equal: 'blue',
exact_amounts: 'green',
percentage: 'purple',
shares: 'orange',
item_based: 'teal'
}
return colors[type] || 'grey'
}
const getSplitTypeBadgeVariant = (type: string): string => {
const colorMap: Record<string, string> = {
equal: 'info',
exact_amounts: 'success',
percentage: 'accent', // Using accent for purple
shares: 'warning',
item_based: 'secondary', // Using secondary for teal as an example
};
return colorMap[type] || 'neutral';
};
onMounted(() => {
fetchGroupDetails();

File diff suppressed because it is too large Load Diff

View File

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