Компоненты MNRFY
Создание переиспользуемых компонентов для упрощения разработки
Основы компонентов
Компоненты в MNRFY - это переиспользуемые части шаблонов, которые можно вызывать с различными параметрами. Они хранятся в директории /src/components/.
Создание простого компонента
<div class="alert alert-{{ type ?? 'info' }} {{ class ?? '' }}">
{% if dismissible ?? false %}
<button type="button" class="alert-close" onclick="this.parentElement.remove()">
×
</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">×</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">
« {{ 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') }} »
</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': 'Пользователи не найдены'
} %}
Лучшие практики
Именование компонентов
- Используйте kebab-case:
user-card.html,modal-confirm.html - Группируйте по функциональности:
form-field.html,table-cell.html - Используйте описательные имена:
price-display.html,status-badge.html
Организация параметров
{% 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
} %}
Следующий шаг: Изучите макеты и наследование.