Компоненты MNRFY

Создание переиспользуемых компонентов для упрощения разработки

Основы компонентов

Компоненты в MNRFY - это переиспользуемые части шаблонов, которые можно вызывать с различными параметрами. Они хранятся в директории /src/components/.

Создание простого компонента

<div class="alert alert-{{ type ?? 'info' }} {{ class ?? '' }}">
    {% if dismissible ?? false %}
        <button type="button" class="alert-close" onclick="this.parentElement.remove()">
            &times;
        </button>
    {% endif %}
    
    {% if icon ?? false %}
        <i class="fas fa-{{ icon }}"></i>
    {% endif %}
    
    {% if title ?? false %}
        <h4 class="alert-title">{{ title }}</h4>
    {% endif %}
    
    <div class="alert-message">
        {{ message }}
    </div>
</div>

Использование компонента

{% set success_message = 'Данные успешно сохранены!' %}
{% set error_title = 'Ошибка!' %}
{% set error_message = 'Произошла ошибка при сохранении' %}
{% set info_message = 'Проверьте указанные данные' %}

<!-- Простое использование с переменной success_message -->
{% component 'alert' with {
    'type': 'success',
    'message': success_message
} %}

<!-- С дополнительными параметрами и переменными error_title, error_message -->
{% component 'alert' with {
    'type': 'danger',
    'icon': 'exclamation-triangle',
    'title': error_title,
    'message': error_message,
    'dismissible': true,
    'class': 'mb-4'
} %}

<!-- Информационное уведомление с переменной info_message -->
{% component 'alert' with {
    'type': 'info',
    'icon': 'info-circle',
    'message': info_message
} %}

Компоненты с слотами

Создание компонента с слотами

<!-- /src/components/card.html -->
<div class="card {{ class ?? '' }}">
    {% if slot('header') or isset(title) %}
        <div class="card-header">
            {% if slot('header') %}
                {!! slot('header') !!}
            {% else %}
                <h5 class="card-title">{{ title }}</h5>
            {% endif %}
        </div>
    {% endif %}
    
    <div class="card-body">
        {% if slot('body') %}
            {!! slot('body') !!}
        {% else %}
            {{ content }}
        {% endif %}
    </div>
    
    {% if slot('footer') %}
        <div class="card-footer">
            {!! slot('footer') !!}
        </div>
    {% endif %}
</div>

Использование компонента со слотами

{% set user = {
    'id': 42,
    'name': 'Иван Петров',
    'email': 'ivan@example.com',
    'avatar': '/images/avatars/ivan.jpg',
    'bio': 'Веб-разработчик с 5-летним опытом работы.\nЛюблю создавать красивые и функциональные интерфейсы.',
    'created_at': '2019-03-15 10:30:00',
    'is_online': true
} %}

<!-- Простая карточка с переменной user.bio -->
{% component 'card' with {
    'title': 'Профиль пользователя',
    'content': user.bio
} %}

<!-- Карточка со слотами и данными пользователя -->
{% component 'card' with {'class': 'user-card'} %}
    {% slot 'header' %}
        <div class="d-flex align-items-center">
            <img src="{{ user.avatar }}" class="avatar-sm me-2">
            <h5>{{ user.name }}</h5>
            {% if user.is_online %}
                <span class="badge badge-success ms-auto">Онлайн</span>
            {% endif %}
        </div>
    {% endslot %}
    
    {% slot 'body' %}
        <p><strong>Email:</strong> {{ user.email }}</p>
        <p><strong>Регистрация:</strong> {{ user.created_at | date('d.m.Y') }}</p>
        {% if user.bio %}
            <p>{{ user.bio | nl2br }}</p>
        {% endif %}
    {% endslot %}
    
    {% slot 'footer' %}
        <a href="/users/{{ user.id }}" class="btn btn-primary">Просмотр профиля</a>
        {% if can('edit_users') %}
            <a href="/users/{{ user.id }}/edit" class="btn btn-secondary">Редактировать</a>
        {% endif %}
    {% endslot %}
{% endcomponent %}

Сложные компоненты

Компонент модального окна

<!-- /src/components/modal.html -->
<div class="modal fade" id="{{ id }}" tabindex="-1" role="dialog" 
     aria-labelledby="{{ id }}Label" aria-hidden="true">
    <div class="modal-dialog {{ isset(size) ? 'modal-' ~ size : '' }}" role="document">
        <div class="modal-content">
            {% if slot('header') or title %}
                <div class="modal-header">
                    {% if slot('header') %}
                        {!! slot('header') !!}
                    {% else %}
                        <h5 class="modal-title" id="{{ id }}Label">{{ title }}</h5>
                    {% endif %}
                    
                    <button type="button" class="modal-close" data-dismiss="modal" 
                            aria-label="{{ t('modal.close') }}">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
            {% endif %}
            
            <div class="modal-body">
                {% if slot('body') %}
                    {!! slot('body') !!}
                {% else %}
                    {{ content }}
                {% endif %}
            </div>
            
            {% if slot('footer') %}
                <div class="modal-footer">
                    {!! slot('footer') !!}
                </div>
            {% endif %}
        </div>
    </div>
</div>

Использование модального окна

{% set info_message = 'Это информационное сообщение' %}
{% set modal_title_info = 'Информация' %}
{% set modal_title_confirm = t('modal.confirm_delete') %}

<!-- Простое модальное окно с переменными -->
{% component 'modal' with {
    'id': 'infoModal',
    'title': modal_title_info,
    'content': info_message
} %}

<!-- Модальное окно подтверждения с переменной modal_title_confirm -->
{% component 'modal' with {
    'id': 'confirmDelete',
    'title': modal_title_confirm,
    'size': 'sm'
} %}
    {% slot 'body' %}
        <p>{{ t('modal.delete_confirmation') }}</p>
        <p class="text-muted">{{ t('modal.action_irreversible') }}</p>
    {% endslot %}
    
    {% slot 'footer' %}
        <button type="button" class="btn btn-secondary" data-dismiss="modal">
            {{ t('button.cancel') }}
        </button>
        <button type="button" class="btn btn-danger" id="confirmDeleteBtn">
            {{ t('button.delete') }}
        </button>
    {% endslot %}
{% endcomponent %}

Компонент формы

<!-- /src/components/form-field.html -->
<div class="form-group {{ class ?? '' }}">
    {% if isset(label) and label %}
        <label for="{{ name }}" class="form-label">
            {{ label }}
            {% if isset(required) and required %}<span class="text-danger">*</span>{% endif %}
        </label>
    {% endif %}
    
    {% set field_type = type ?? 'text' %}
    {% switch field_type %}
        {% case 'textarea' %}
            <textarea id="{{ name }}" name="{{ name }}" 
                      class="form-control{% if isset(errors) and isset(errors[name]) and errors[name] %} is-invalid{% endif %}"
                      {% if isset(required) and required %}required{% endif %}
                      {% if isset(placeholder) %}placeholder="{{ placeholder }}"{% endif %}
                      {% if isset(rows) %}rows="{{ rows }}"{% endif %}>{{ value ?? old(name) ?? '' }}</textarea>
        
        {% case 'select' %}
            <select id="{{ name }}" name="{{ name }}" 
                    class="form-control{% if isset(errors) and isset(errors[name]) and errors[name] %} is-invalid{% endif %}"
                    {% if isset(required) and required %}required{% endif %}>
                {% if isset(placeholder) %}
                    <option value="">{{ placeholder }}</option>
                {% endif %}
                {% if isset(options) %}
                    {% foreach options as option_value => option_label %}
                        {% set current_value = value ?? old(name) ?? '' %}
                        <option value="{{ option_value }}" 
                                {% if current_value == option_value %}selected{% endif %}>
                            {{ option_label }}
                        </option>
                    {% endforeach %}
                {% endif %}
            </select>
        
        {% case 'checkbox' %}
            <div class="form-check">
                <input type="checkbox" id="{{ name }}" name="{{ name }}" 
                       value="1" class="form-check-input"
                       {% if value ?? old(name) ?? false %}checked{% endif %}>
                {% if isset(label) and label %}
                    <label for="{{ name }}" class="form-check-label">{{ label }}</label>
                {% endif %}
            </div>
        
        {% default %}
            <input type="{{ field_type }}" id="{{ name }}" name="{{ name }}" 
                   class="form-control{% if isset(errors) and isset(errors[name]) and errors[name] %} is-invalid{% endif %}"
                   value="{{ value ?? old(name) ?? '' }}"
                   {% if isset(required) and required %}required{% endif %}
                   {% if isset(placeholder) %}placeholder="{{ placeholder }}"{% endif %}
                   {% if isset(min) %}min="{{ min }}"{% endif %}
                   {% if isset(max) %}max="{{ max }}"{% endif %}>
    {% endswitch %}
    
    {% if isset(help) and help %}
        <small class="form-text text-muted">{{ help }}</small>
    {% endif %}
    
    {% if isset(errors) and isset(errors[name]) and errors[name] %}
        <div class="invalid-feedback">{{ errors[name] }}</div>
    {% endif %}
</div>

Использование компонента формы

{% set errors = {
    'email': 'Неверный формат email адреса',
    'role': 'Выберите роль пользователя'
} %}

{% set role_options = {
    'user': t('role.user'),
    'moderator': t('role.moderator'),
    'admin': t('role.admin')
} %}

<form method="POST" action="/users">
    {{ csrf() }}
    
    <!-- Поле логина без ошибок -->
    {% component 'form-field' with {
        'name': 'login',
        'label': t('form.login'),
        'type': 'text',
        'required': true,
        'placeholder': t('form.enter_login'),
        'help': t('form.login_help')
    } %}
    
    <!-- Поле email с ошибкой из массива errors -->
    {% component 'form-field' with {
        'name': 'email',
        'label': t('form.email'),
        'type': 'email',
        'required': true,
        'errors': errors
    } %}
    
    <!-- Поле выбора роли с опциями из role_options и ошибкой -->
    {% component 'form-field' with {
        'name': 'role',
        'label': t('form.role'),
        'type': 'select',
        'options': role_options,
        'placeholder': t('form.select_role'),
        'errors': errors
    } %}
    
    <!-- Текстовое поле биографии -->
    {% component 'form-field' with {
        'name': 'bio',
        'label': t('form.bio'),
        'type': 'textarea',
        'rows': 4,
        'placeholder': t('form.tell_about_yourself')
    } %}
    
    <!-- Чекбокс активности аккаунта -->
    {% component 'form-field' with {
        'name': 'active',
        'label': t('form.active_account'),
        'type': 'checkbox'
    } %}
    
    <button type="submit" class="btn btn-primary">{{ t('button.create') }}</button>
</form>

Компоненты с логикой

Компонент пагинации

<!-- /src/components/pagination.html -->
{% if data.last_page > 1 %}
    <nav class="pagination-nav" aria-label="{{ t('pagination.navigation') }}">
        <div class="pagination-info">
            {{ t('pagination.showing') }}
            <strong>{{ data.from }}</strong> - <strong>{{ data.to }}</strong>
            {{ t('pagination.of') }}
            <strong>{{ data.total }}</strong>
            {{ t('pagination.results') }}
        </div>
        
        <ul class="pagination">
            <!-- Первая страница показывается если текущая страница больше 3 -->
            {% if data.current_page > 3 %}
                <li class="page-item">
                    <a class="page-link" href="{{ build_url(1) }}">1</a>
                </li>
                {% if data.current_page > 4 %}
                    <li class="page-item disabled">
                        <span class="page-link">...</span>
                    </li>
                {% endif %}
            {% endif %}
            
            <!-- Ссылка на предыдущую страницу если есть data.prev_page -->
            {% if data.prev_page %}
                <li class="page-item">
                    <a class="page-link" href="{{ build_url(data.prev_page) }}" rel="prev">
                        &laquo; {{ t('pagination.previous') }}
                    </a>
                </li>
            {% endif %}
            
            <!-- Страницы вокруг текущей (current_page - 2 до current_page + 2) -->
            {% for page in (data.current_page - 2)..(data.current_page + 2) %}
                {% if page >= 1 and page <= data.last_page %}
                    <li class="page-item {{ page == data.current_page ? 'active' : '' }}">
                        {% if page == data.current_page %}
                            <span class="page-link">{{ page }}</span>
                        {% else %}
                            <a class="page-link" href="{{ build_url(page) }}">{{ page }}</a>
                        {% endif %}
                    </li>
                {% endif %}
            {% endfor %}
            
            <!-- Ссылка на следующую страницу если есть data.next_page -->
            {% if data.next_page %}
                <li class="page-item">
                    <a class="page-link" href="{{ build_url(data.next_page) }}" rel="next">
                        {{ t('pagination.next') }} &raquo;
                    </a>
                </li>
            {% endif %}
            
            <!-- Последняя страница показывается если до неё больше 2 страниц -->
            {% if data.current_page < data.last_page - 2 %}
                {% if data.current_page < data.last_page - 3 %}
                    <li class="page-item disabled">
                        <span class="page-link">...</span>
                    </li>
                {% endif %}
                <li class="page-item">
                    <a class="page-link" href="{{ build_url(data.last_page) }}">
                        {{ data.last_page }}
                    </a>
                </li>
            {% endif %}
        </ul>
    </nav>
{% endif %}

{% php %}
function build_url($page) {
    $query = $_GET;
    $query['page'] = $page;
    return '?' . http_build_query($query);
}
{% endphp %}

Компонент хлебных крошек

<!-- /src/components/breadcrumb.html -->
{% if items and items | length > 0 %}
    <nav aria-label="{{ t('breadcrumb.navigation') }}">
        <ol class="breadcrumb">
            {% if show_home %}
                <li class="breadcrumb-item">
                    <a href="{{ route('home') }}">
                        {% if home_icon %}
                            <i class="fas fa-home"></i>
                        {% else %}
                            {{ t('breadcrumb.home') }}
                        {% endif %}
                    </a>
                </li>
            {% endif %}
            
            <!-- Цикл по элементам breadcrumb, последний элемент без ссылки -->
            {% foreach items as item %}
                {% if loop.last %}
                    <li class="breadcrumb-item active" aria-current="page">
                        {{ item.title }}
                    </li>
                {% else %}
                    <li class="breadcrumb-item">
                        {% if item.url %}
                            <a href="{{ item.url }}">{{ item.title }}</a>
                        {% else %}
                            {{ item.title }}
                        {% endif %}
                    </li>
                {% endif %}
            {% endforeach %}
        </ol>
    </nav>
{% endif %}

Использование хлебных крошек

{% set breadcrumb_items = [
    {'title': 'Админ-панель', 'url': route('admin')},
    {'title': 'Пользователи', 'url': route('admin.users')},
    {'title': 'Редактирование'}
] %}

<!-- В шаблоне страницы используем массив breadcrumb_items -->
{% component 'breadcrumb' with {
    'show_home': true,
    'home_icon': true,
    'items': breadcrumb_items
} %}

Вложенные компоненты

Компонент таблицы данных

<!-- /src/components/data-table.html -->

{% php %}
function build_action_url($url, $row) {
    return str_replace(
        array_map(fn($key) => '{' . $key . '}', array_keys($row)),
        array_values($row),
        $url
    );
}
{% endphp %}

<div class="table-responsive">
    <table class="table {{ table_class ?? 'table-striped table-hover' }}">
        {% if columns %}
            <thead class="{{ thead_class ?? 'table-dark' }}">
                <tr>
                    {% if selectable %}
                        <th width="50">
                            <input type="checkbox" class="select-all" 
                                   onchange="toggleSelectAll(this)">
                        </th>
                    {% endif %}
                    
                    <!-- Цикл по массиву columns для создания заголовков -->
                    {% foreach columns as column %}
                        <th {% if column.width %}width="{{ column.width }}"{% endif %}
                            {% if column.sortable %}
                                class="sortable"
                                onclick="sortTable('{{ column.field }}')"
                            {% endif %}>
                            {{ column.title }}
                            {% if column.sortable %}
                                <i class="fas fa-sort"></i>
                            {% endif %}
                        </th>
                    {% endforeach %}
                    
                    {% if actions %}
                        <th width="{{ actions_width ?? '120' }}">
                            {{ t('table.actions') }}
                        </th>
                    {% endif %}
                </tr>
            </thead>
        {% endif %}
        
        <tbody>
            {% if data and data | length > 0 %}
                <!-- Цикл по массиву data для создания строк таблицы -->
                {% foreach data as row %}
                    <tr data-id="{{ row['id'] }}">
                        {% if selectable %}
                            <td>
                                <input type="checkbox" class="row-select" 
                                       value="{{ row['id'] }}">
                            </td>
                        {% endif %}
                        
                        <!-- Для каждой колонки создаем ячейку с данными из row -->
                        {% foreach columns as column %}
                            <td class="{{ column.class ?? '' }}">
                                {% component 'table-cell' with {
                                    'value': row[column.field],
                                    'type': column.type ?? 'text',
                                    'format': column.format ?? null,
                                    'row': row
                                } %}
                            </td>
                        {% endforeach %}
                        
                        {% if actions %}
                            <td class="table-actions">
                                {% foreach actions as action %}
                                    {# component 'table-action' with {
                                        'action': action,
                                        'row': row
                                    } #}
                                {% endforeach %}
                            </td>
                        {% endif %}
                    </tr>
                {% endforeach %}
            {% else %}
                <tr>
                    <td colspan="{{ (columns | length) + (selectable ? 1 : 0) + (actions ? 1 : 0) }}" 
                        class="text-center text-muted py-4">
                        {{ empty_message ?? t('table.no_data') }}
                    </td>
                </tr>
            {% endif %}
        </tbody>
    </table>
</div>

Дочерние компоненты таблицы

<!-- /src/components/table-cell.html -->
{% switch type %}
    {% case 'date' %}
        {{ value | date(format ?? 'Y-m-d') }}
    {% case 'datetime' %}
        {{ value | date(format ?? 'Y-m-d H:i') }}
    {% case 'money' %}
        {{ value | money(format ?? '₽') }}
    {% case 'status' %}
        <span class="badge badge-{{ value ? 'success' : 'danger' }}">
            {{ value ? 'Активен' : 'Неактивен' }}
        </span>
    {% case 'image' %}
        {% if value %}
            <img src="{{ value }}" alt="" style="width: 50px; height: 50px; object-fit: cover;">
        {% else %}
            <span class="text-muted">Нет изображения</span>
        {% endif %}
    {% default %}
        {{ value | truncate(50) }}
{% endswitch %}

<!-- /src/components/table-action.html -->
{% if action.type == 'link' %}
    {% set url = action.url %}
    {% foreach row as key, value %}
        {% set url = url | replace('{' ~ key ~ '}', value) %}
    {% endforeach %}
    
    <a href="{{ url }}" 
       class="btn btn-sm btn-{{ action.style ?? 'primary' }}"
       {% if action.confirm %}onclick="return confirm('{{ action.confirm }}')"{% endif %}>
        {% if action.icon %}<i class="fas fa-{{ action.icon }}"></i>{% endif %}
        {{ action.title }}
    </a>
{% elseif action.type == 'button' %}
    {% set onclick = action.onclick %}
    {% foreach row as key, value %}
        {% set onclick = onclick | replace('{' ~ key ~ '}', value) %}
    {% endforeach %}
    
    <button type="button" class="btn btn-sm btn-{{ action.style ?? 'primary' }}"
            onclick="{{ onclick }}"
            {% if action.confirm %}data-confirm="{{ action.confirm }}"{% endif %}>
        {% if action.icon %}<i class="fas fa-{{ action.icon }}"></i>{% endif %}
        {{ action.title }}
    </button>
{% endif %}
{% set users_data = [
    {
        'id': 1,
        'name': 'Иван Петров',
        'email': 'ivan@example.com',
        'status': true,
        'created_at': '2023-01-15 10:30:00',
        'avatar': '/images/avatars/ivan.jpg'
    },
    {
        'id': 2,
        'name': 'Мария Сидорова',
        'email': 'maria@example.com',
        'status': false,
        'created_at': '2023-02-20 14:45:00',
        'avatar': null
    }
] %}

{% set table_columns = [
    {'title': 'ID', 'field': 'id', 'width': '50', 'sortable': true},
    {'title': 'Аватар', 'field': 'avatar', 'type': 'image', 'width': '80'},
    {'title': 'Имя', 'field': 'name', 'sortable': true},
    {'title': 'Email', 'field': 'email', 'sortable': true},
    {'title': 'Статус', 'field': 'status', 'type': 'status', 'width': '100'},
    {'title': 'Дата регистрации', 'field': 'created_at', 'type': 'datetime', 'width': '150'}
] %}

{% set table_actions = [
    {
        'type': 'link',
        'title': 'Просмотр',
        'url': '/users/{id}',
        'icon': 'eye',
        'style': 'info'
    },
    {
        'type': 'link',
        'title': 'Редактировать',
        'url': '/users/{id}/edit',
        'icon': 'edit',
        'style': 'warning'
    },
    {
        'type': 'button',
        'title': 'Удалить',
        'onclick': 'deleteUser({id})',
        'icon': 'trash',
        'style': 'danger',
        'confirm': 'Вы уверены что хотите удалить пользователя?'
    }
] %}

<!-- Использование таблицы с подготовленными данными -->
{% component 'data-table' with {
    'columns': table_columns,
    'data': users_data,
    'actions': table_actions,
    'selectable': true,
    'empty_message': 'Пользователи не найдены'
} %}

Лучшие практики

Именование компонентов

Организация параметров

{% set button_text = 'Сохранить' %}
{% set button_icon = 'save' %}
{% set form_disabled = false %}

<!-- Хорошо: четкие параметры с значениями по умолчанию -->
{% component 'button' with {
    'type': 'submit',
    'style': 'primary',
    'size': 'md',
    'text': button_text,
    'icon': button_icon,
    'disabled': form_disabled
} %}

<!-- Плохо: неясные параметры -->
{% component 'button' with {
    'a': 'submit',
    'b': 'primary',
    'c': button_text
} %}

Документирование компонентов

{% set demo_user = {
    'id': 123,
    'name': 'Александр Иванов',
    'avatar': '/images/avatars/alex.jpg',
    'is_online': true
} %}

<!-- /src/components/user-avatar.html -->
{#
  Компонент аватара пользователя
  
  Параметры:
  - user (object, required): объект пользователя
  - size (string, default: 'md'): размер аватара (sm, md, lg, xl)  
  - show_status (boolean, default: false): показывать статус онлайн
  - clickable (boolean, default: true): делать аватар кликабельным
  - class (string, optional): дополнительные CSS классы
#}

{% set avatar_sizes = {
    'sm': 32,
    'md': 48,
    'lg': 64,
    'xl': 96
} %}

{% set avatar_size = avatar_sizes[size ?? 'md'] %}

<div class="user-avatar avatar-{{ size ?? 'md' }} {{ class ?? '' }}">
    {% if clickable %}
        <a href="/users/{{ user.id }}">
    {% endif %}
    
    {% if user.avatar %}
        <img src="{{ user.avatar }}" 
             alt="{{ user.name }}"
             width="{{ avatar_size }}"
             height="{{ avatar_size }}">
    {% else %}
        <div class="avatar-placeholder" 
             style="width: {{ avatar_size }}px; height: {{ avatar_size }}px;">
            {{ user.name | first | upper }}
        </div>
    {% endif %}
    
    {% if show_status and user.is_online %}
        <span class="status-indicator online"></span>
    {% endif %}
    
    {% if clickable %}
        </a>
    {% endif %}
</div>

<!-- Примеры использования компонента с demo_user -->
{% component 'user-avatar' with {
    'user': demo_user,
    'size': 'lg',
    'show_status': true,
    'clickable': true
} %}
Следующий шаг: Изучите макеты и наследование.