Files
LogWhispererAI/.opencode/skills/examples/modal-implementation.md
Luca Sacchi Ricciardi aa489c7eb8 docs: add comprehensive frontend landing page plan and download design skills
Add detailed landing page development plan in docs/frontend_landing_plan.md:
- Complete landing page structure (Hero, Problem/Solution, Features, Demo, CTA)
- Design guidelines from downloaded skills (typography, color, motion, composition)
- Security considerations (XSS prevention, input sanitization, CSP)
- Performance targets (LCP <2.5s, bundle <150KB, Lighthouse >90)
- Responsiveness and accessibility requirements (WCAG 2.1 AA)
- Success KPIs and monitoring setup
- 3-week development timeline with daily tasks
- Definition of Done checklist

Download 10+ frontend/UI/UX skills via universal-skills-manager:
- frontend-ui-ux: UI/UX design without mockups
- frontend-design-guidelines: Production-grade interface guidelines
- frontend-developer: React best practices (40+ rules)
- frontend-engineer: Next.js 14 App Router patterns
- ui-ux-master: Comprehensive design systems and accessibility
- ui-ux-systems-designer: Information architecture and interaction
- ui-ux-design-user-experience: Platform-specific guidelines
- Plus additional reference materials and validation scripts

Configure universal-skills MCP with SkillsMP API key for curated skill access.

Safety first: All skills validated before installation, no project code modified.

Refs: Universal Skills Manager (github:jacob-bd/universal-skills-manager)
Next: Begin Sprint 3 landing page development
2026-04-03 13:13:59 +02:00

16 KiB
Raw Blame History

Modal Implementation Example: Transaction Modal

Реальный пример из Family Budget: модальное окно для создания транзакции.

Files

  • Template: frontend/web/templates/components/modal_transaction.html
  • JavaScript: frontend/web/static/js/budget/transactionForm.js
  • Usage: frontend/web/templates/index.html

Architecture

┌─────────────────────────────────────────────────────────┐
│ index.html (parent template)                            │
│                                                          │
│  {% from "components/modal_transaction.html" import     │
│         transaction_modal %}                             │
│                                                          │
│  {{ transaction_modal(modal_id='modal_add_transaction') }}│
└─────────────────────────────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ modal_transaction.html (Jinja2 macro)                   │
│                                                          │
│  <dialog id="{{ modal_id }}" class="modal">             │
│    <div class="modal-box">                              │
│      <form id="form_{{ modal_id }}">                    │
│        <!-- Form fields -->                             │
│      </form>                                             │
│    </div>                                                │
│  </dialog>                                               │
└─────────────────────────────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ transactionForm.js (CRUD logic)                         │
│                                                          │
│  - saveTransaction() - POST /api/v1/facts               │
│  - updateTransaction() - PUT /api/v1/facts/{id}         │
│  - deleteTransaction() - DELETE /api/v1/facts/{id}      │
│  - Offline sync via IndexedDB                           │
│  - WebSocket broadcast refresh                          │
└─────────────────────────────────────────────────────────┘

Implementation

1. Jinja2 Macro (modal_transaction.html)

{% macro transaction_modal(modal_id='modal_add_transaction') %}
<dialog id="{{ modal_id }}" class="modal">
    <div class="modal-box w-full max-w-2xl p-4 max-h-[90vh] overflow-y-auto my-auto">
        <h3 class="font-bold text-lg mb-3"> Добавить факт</h3>

        <form id="form_{{ modal_id }}" class="space-y-2">
            <!-- Дата -->
            <div class="form-control">
                <label class="label py-1">
                    <span class="label-text">Дата *</span>
                </label>
                <!-- Quick date buttons -->
                <div class="flex gap-1 mb-1">
                    <button type="button" class="btn btn-xs btn-outline flex-1"
                            onclick="setTransactionDate(0)">Сегодня</button>
                    <button type="button" class="btn btn-xs btn-outline flex-1"
                            onclick="setTransactionDate(-1)">Вчера</button>
                    <button type="button" class="btn btn-xs btn-outline flex-1"
                            onclick="setTransactionDate(-2)">Позавчера</button>
                </div>
                <input type="text" name="fact_date" required
                       class="input input-bordered w-full transaction-date-input"
                       placeholder="ДД.ММ.ГГГГ" maxlength="10" />
            </div>

            <!-- Счет (Financial Center) -->
            <div class="form-control">
                <label class="label py-1">
                    <span class="label-text">Счет *</span>
                </label>
                <select name="financial_center_id" required class="select select-bordered">
                    <option value="">-- Выберите счет --</option>
                </select>
            </div>

            <!-- Тип операции (Radio buttons as DaisyUI buttons) -->
            <div class="form-control">
                <label class="label py-1">
                    <span class="label-text font-semibold">Тип операции *</span>
                </label>
                <div class="grid grid-cols-2 gap-2">
                    <label class="btn btn-sm btn-outline btn-error transaction-type-btn btn-active"
                           data-type="expense">
                        <input type="radio" name="record_type" value="expense" class="hidden" checked />
                        Расход
                    </label>
                    <label class="btn btn-sm btn-outline btn-success transaction-type-btn"
                           data-type="income">
                        <input type="radio" name="record_type" value="income" class="hidden" />
                        Доход
                    </label>
                </div>
            </div>

            <!-- Категория (Article) - filtered by Financial Center -->
            <div class="form-control">
                <label class="label py-1">
                    <span class="label-text">Категория *</span>
                </label>
                <select name="article_id" required class="select select-bordered">
                    <option value="" disabled hidden>-- Выберите категорию --</option>
                </select>
            </div>

            <!-- Сумма -->
            <div class="form-control">
                <label class="label py-1">
                    <span class="label-text">Сумма *</span>
                </label>
                <input type="number" name="amount" step="1" min="1" required
                       class="input input-bordered" placeholder="0" />
            </div>

            <!-- Описание -->
            <div class="form-control">
                <label class="label py-1">
                    <span class="label-text">Описание</span>
                </label>
                <textarea name="description" class="textarea textarea-bordered"
                          placeholder="Комментарий" rows="1"></textarea>
            </div>

            <!-- Modal Actions -->
            <div class="modal-action mt-3">
                <button type="button" onclick="{{ modal_id }}.close()" class="btn btn-sm btn-ghost">
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                    </svg>
                    Отмена
                </button>

                <!-- Unified save button (green online, orange offline) -->
                <button type="button" class="btn btn-sm save-btn"
                        data-form-id="form_{{ modal_id }}"
                        data-modal-id="{{ modal_id }}"
                        onclick="saveTransaction(this)"
                        title="Сохранить">
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
                    </svg>
                    Сохранить
                </button>
            </div>
        </form>
    </div>

    <!-- Click backdrop to close -->
    <form method="dialog" class="modal-backdrop">
        <button>close</button>
    </form>
</dialog>
{% endmacro %}

2. JavaScript Logic (transactionForm.js)

/**
 * Save transaction (CREATE or UPDATE)
 * Supports online/offline modes
 */
async function saveTransaction(button) {
    const formId = button.dataset.formId;
    const modalId = button.dataset.modalId;
    const form = document.getElementById(formId);
    const modal = document.getElementById(modalId);

    // Validate form
    if (!form.checkValidity()) {
        form.reportValidity();
        return;
    }

    // Get form data
    const formData = new FormData(form);
    const data = Object.fromEntries(formData.entries());

    // Convert types
    data.amount = parseFloat(data.amount);
    data.financial_center_id = parseInt(data.financial_center_id);
    data.article_id = parseInt(data.article_id);
    if (data.cost_center_id) {
        data.cost_center_id = parseInt(data.cost_center_id);
    }

    try {
        // Check if online or offline
        if (navigator.onLine) {
            // Online: POST to API
            const response = await fetch('/api/v1/facts', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                credentials: 'same-origin',
                body: JSON.stringify(data)
            });

            if (!response.ok) {
                const error = await response.json();
                throw new Error(error.detail || 'Failed to save transaction');
            }

            const result = await response.json();
            console.log('Transaction saved:', result.id);

            // Close modal
            modal.close();

            // Refresh data via HTMX
            htmx.trigger('#recent-transactions', 'refresh');
            htmx.trigger('#quick-stats', 'refresh');

            // Show success toast
            showToast('Транзакция сохранена', 'success');

        } else {
            // Offline: Save to IndexedDB
            await window.offlineSync.queueTransaction(data);

            // Close modal
            modal.close();

            // Show offline toast
            showToast('Сохранено offline (синхронизируется при подключении)', 'warning');
        }

    } catch (error) {
        console.error('Save transaction error:', error);
        showToast(error.message || 'Ошибка сохранения', 'error');
    }
}

/**
 * Open modal for creating new transaction
 */
function openAddTransactionModal() {
    const modal = document.getElementById('modal_add_transaction');
    const form = document.getElementById('form_modal_add_transaction');

    // Reset form
    form.reset();

    // Set default date (today)
    setTransactionDate(0);

    // Set default type (expense)
    document.querySelector('input[name="record_type"][value="expense"]').checked = true;
    document.querySelectorAll('.transaction-type-btn').forEach(btn => {
        btn.classList.remove('btn-active');
    });
    document.querySelector('.transaction-type-btn[data-type="expense"]').classList.add('btn-active');

    // Load Financial Centers and Articles
    loadFormOptions();

    // Open modal
    modal.showModal();
}

/**
 * Set transaction date
 * @param {number} daysOffset - Days offset from today (0=today, -1=yesterday, etc.)
 */
function setTransactionDate(daysOffset) {
    const date = new Date();
    date.setDate(date.getDate() + daysOffset);

    const dateStr = date.toLocaleDateString('ru-RU', {
        day: '2-digit',
        month: '2-digit',
        year: 'numeric'
    });

    const input = document.querySelector('.transaction-date-input');
    if (input) {
        input.value = dateStr;
    }
}

/**
 * Load Financial Centers and Articles for form
 */
async function loadFormOptions() {
    try {
        // Load Financial Centers
        const fcResponse = await fetch('/api/v1/financial-centers');
        const financialCenters = await fcResponse.json();

        const fcSelect = document.querySelector('select[name="financial_center_id"]');
        fcSelect.innerHTML = '<option value="">-- Выберите счет --</option>';
        financialCenters.forEach(fc => {
            fcSelect.innerHTML += `<option value="${fc.id}">${fc.name}</option>`;
        });

        // Load Articles (filtered by Financial Center after selection)
        const articlesResponse = await fetch('/api/v1/articles');
        const articles = await articlesResponse.json();

        const articleSelect = document.querySelector('select[name="article_id"]');
        articleSelect.innerHTML = '<option value="">-- Выберите категорию --</option>';
        articles.forEach(article => {
            articleSelect.innerHTML += `<option value="${article.id}">${article.name}</option>`;
        });

    } catch (error) {
        console.error('Load form options error:', error);
        showToast('Ошибка загрузки справочников', 'error');
    }
}

3. Usage in Template

{% extends "base.html" %}
{% from "components/modal_transaction.html" import transaction_modal %}

{% block content %}
<!-- Page content -->
<div class="space-y-6">
    <!-- Quick actions -->
    <button onclick="openAddTransactionModal()" class="btn btn-success">
         Добавить факт
    </button>

    <!-- Recent transactions -->
    <div id="recent-transactions" hx-get="/api/v1/facts/recent" hx-trigger="load">
        <!-- Loaded via HTMX -->
    </div>
</div>

<!-- Modal (outside content div) -->
{{ transaction_modal(modal_id='modal_add_transaction') }}
{% endblock %}

Key Features

1. DaisyUI Modal

<dialog id="modal_id" class="modal">
  <div class="modal-box">...</div>
  <form method="dialog" class="modal-backdrop">
    <button>close</button>
  </form>
</dialog>
  • Native <dialog> element (HTML5)
  • DaisyUI classes: modal, modal-box, modal-backdrop
  • JavaScript API: modal.showModal(), modal.close()

2. Form Validation

  • HTML5 validation: required, min, maxlength
  • JavaScript: form.checkValidity(), form.reportValidity()
  • Custom validation in saveTransaction()

3. Offline Support

  • Check navigator.onLine
  • Save to IndexedDB when offline
  • Sync queue processed when back online
  • Visual feedback (orange button when offline)

4. HTMX Integration

// Refresh HTMX content after save
htmx.trigger('#recent-transactions', 'refresh');
htmx.trigger('#quick-stats', 'refresh');

5. Radio Buttons as Buttons

<label class="btn btn-outline btn-error transaction-type-btn">
  <input type="radio" name="record_type" value="expense" class="hidden" />
  Расход
</label>
  • Styled as DaisyUI buttons
  • Hidden radio input
  • Toggle btn-active class on click

Performance

  • Modal open: <100ms (minimal JavaScript)
  • Form load: ~200ms (2 API calls in parallel)
  • Save: ~150ms (POST + HTMX refresh)
  • Offline save: ~50ms (IndexedDB write)

Accessibility

  • Keyboard navigation (Tab, Enter, Esc)
  • ARIA labels (implicit from <label> elements)
  • Focus management (auto-focus first input when opened)
  • Screen reader compatible (DaisyUI semantic HTML)

Browser Support

  • Chrome/Edge (full support)
  • Firefox (full support)
  • Safari (full support, including iOS)
  • Mobile browsers (responsive design)

References