Skip to Content

HTMX и Yoyo компоненты

Flute CMS интегрирует HTMX и Yoyo для создания динамичных веб-приложений без необходимости написания JavaScript кода. HTMX позволяет обновлять части страницы без перезагрузки, а Yoyo предоставляет PHP-классы для управления состоянием компонентов.

Нюансы ядра:

  • Маршрут Yoyo регистрируется автоматически (/live или /admin/live) с middleware web, csrf
  • HtmxMiddleware (алиас htmx) вернёт 404, если запрос не HX — используйте его только на HTMX-эндпоинтах
  • ModuleServiceProvider::loadComponents() автоматически регистрирует классы из Components/ в Yoyo (kebab-case без суффикса Component)
  • В админке Yoyo используется через router()->screen() (см. админ-интеграцию)

Архитектура HTMX

Основные возможности HTMX

<!-- Базовое использование HTMX --> <button hx-get="/api/data" hx-target="#result" hx-swap="innerHTML"> Загрузить данные </button> <div id="result"></div>

Атрибуты HTMX

<!-- GET запрос --> <button hx-get="/articles">Загрузить статьи</button> <!-- POST запрос --> <form hx-post="/articles" hx-target="#articles-list"> <input name="title" type="text"> <button type="submit">Создать статью</button> </form> <!-- PUT запрос --> <button hx-put="/articles/1" hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'> Обновить статью </button> <!-- DELETE запрос --> <button hx-delete="/articles/1" hx-confirm="Вы уверены?"> Удалить статью </button>

Цели и обмен контентом

<!-- Замена всего элемента --> <div hx-get="/content" hx-target="#container" hx-swap="innerHTML"> <!-- Контент --> </div> <!-- Добавление в конец --> <div hx-get="/items" hx-target="#list" hx-swap="beforeend"> <ul id="list"> <li>Существующий элемент</li> </ul> </div> <!-- Добавление в начало --> <div hx-get="/messages" hx-target="#chat" hx-swap="afterbegin"> <div id="chat"> <div class="message">Старая сообщение</div> </div> </div> <!-- Замена только текста --> <span hx-get="/time" hx-target="#time" hx-swap="text"> <span id="time">Загрузка...</span> </span>

Для HTMX-ручек добавьте middleware htmx, чтобы защищать их от прямых обращений.

Yoyo компоненты

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

<?php namespace Flute\Modules\Blog\Components; use Flute\Core\Support\FluteComponent; class ArticleLikeComponent extends FluteComponent { public int $articleId; public int $likesCount = 0; public bool $isLiked = false; public function mount(int $articleId) { $this->articleId = $articleId; $this->loadLikeData(); } public function like() { $userId = user()->id; if ($this->isLiked) { // Удаление лайка rep(\Flute\Modules\Blog\Database\Entities\ArticleLike::class) ->select() ->where('article_id', $this->articleId) ->where('user_id', $userId) ->delete(); $this->likesCount--; $this->isLiked = false; } else { // Добавление лайка $like = new \Flute\Modules\Blog\Database\Entities\ArticleLike(); $like->article_id = $this->articleId; $like->user_id = $userId; $like->created_at = now(); transaction($like)->run(); $this->likesCount++; $this->isLiked = true; } // Отправка события $this->emitEvent('article-liked', [ 'article_id' => $this->articleId, 'liked' => $this->isLiked, 'likes_count' => $this->likesCount ]); } protected function loadLikeData() { $userId = user()->id; if ($userId) { $like = rep(\Flute\Modules\Blog\Database\Entities\ArticleLike::class) ->select() ->where('article_id', $this->articleId) ->where('user_id', $userId) ->fetchOne(); $this->isLiked = $like !== null; } $this->likesCount = rep(\Flute\Modules\Blog\Database\Entities\ArticleLike::class) ->select() ->where('article_id', $this->articleId) ->count(); } public function render() { return $this->view('blog::components.article-like', [ 'article_id' => $this->articleId, 'likes_count' => $this->likesCount, 'is_liked' => $this->isLiked ]); } }

FluteComponent добавляет интеграцию с валидатором ($this->validate(...), $this->getValidatorErrors()), троттлингом через TooManyRequestsException и удобными методами confirm()/emit().

Шаблон Yoyo компонента

{{-- resources/views/components/article-like.blade.php --}} @php $buttonClass = $is_liked ? 'btn-danger' : 'btn-outline-danger'; $iconClass = $is_liked ? 'heart-filled' : 'heart'; @endphp <div class="article-like-component"> @auth <form method="POST" class="d-inline"> @csrf <button type="submit" class="btn {{ $buttonClass }} btn-sm like-button" yoyo:post="like()" yoyo:vals="{}" data-article-id="{{ $article_id }}"> <i class="{{ $iconClass }}"></i> <span class="likes-count">{{ $likes_count }}</span> </button> </form> @else <span class="text-muted"> <i class="far fa-heart"></i> <span class="likes-count">{{ $likes_count }}</span> </span> @endauth </div> @push('scripts') <script> // Обработка событий компонента document.addEventListener('article-liked', function(event) { const data = event.detail; console.log('Article liked:', data); }); </script> @endpush

Продвинутые возможности HTMX

Заголовки и параметры запроса

<!-- Отправка заголовков --> <button hx-get="/api/user/profile" hx-headers='{"Authorization": "Bearer {{ auth()->token() }}"}'> Загрузить профиль </button> <!-- Отправка дополнительных данных --> <button hx-post="/api/articles/search" hx-vals='{"category": "tech", "sort": "newest"}'> Поиск статей </button> <!-- Динамические заголовки --> <div hx-get="/api/notifications" hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'> </div>

Обработка ответов

<!-- Обработка успешного ответа --> <button hx-post="/api/contact" hx-target="#contact-form" hx-swap="innerHTML" hx-on:htmx:after-request="handleContactResponse(event)"> Отправить </button> <!-- Обработка ошибок --> <form hx-post="/api/login" hx-target="#login-result" hx-on:htmx:response-error="handleLoginError(event)"> <!-- Поля формы --> </form> <script> function handleContactResponse(event) { if (event.detail.success) { showNotification('Сообщение отправлено!', 'success'); } } function handleLoginError(event) { const error = event.detail.error; showNotification(error, 'error'); } </script>

Синхронизация и очереди

<!-- Синхронизация нескольких запросов --> <button hx-get="/api/data1" hx-sync="this:replace">Загрузить 1</button> <button hx-get="/api/data2" hx-sync="this:replace">Загрузить 2</button> <!-- Очереди запросов --> <div hx-get="/api/slow-operation" hx-queue="first" hx-target="#result"> Медленная операция </div> <div hx-get="/api/fast-operation" hx-queue="last" hx-target="#result"> Быстрая операция </div>

Компоненты с формами

Форма создания статьи

<?php namespace Flute\Modules\Blog\Components; use Flute\Core\Support\FluteComponent; class ArticleFormComponent extends FluteComponent { public ?int $articleId = null; public string $title = ''; public string $content = ''; public ?int $categoryId = null; public array $tags = []; public bool $published = false; public function mount(?int $articleId = null) { $this->articleId = $articleId; if ($articleId) { $this->loadArticle(); } $this->loadCategories(); } public function save() { $this->validateForm(); try { if ($this->articleId) { $this->updateArticle(); $message = __('blog.article_updated'); } else { $this->createArticle(); $message = __('blog.article_created'); } $this->flashMessage($message, 'success'); // Перенаправление или обновление компонента $this->emitEvent('article-saved', [ 'article_id' => $this->articleId ]); } catch (\Exception $e) { $this->flashMessage(__('blog.save_failed'), 'error'); } } public function addTag($tagName) { if (!in_array($tagName, $this->tags)) { $this->tags[] = $tagName; } } public function removeTag($tagIndex) { if (isset($this->tags[$tagIndex])) { unset($this->tags[$tagIndex]); $this->tags = array_values($this->tags); // Переиндексация } } protected function validateForm() { $rules = [ 'title' => 'required|string|min-str-len:3|max-str-len:255', 'content' => 'required|string|min-str-len:10', 'category_id' => 'nullable|integer|exists:blog_categories,id', 'tags' => 'array|max:10', 'tags.*' => 'string|max-str-len:50' ]; $isValid = $this->validate($rules); if (!$isValid) { throw new \Exception('Validation failed'); } } protected function createArticle() { $article = new \Flute\Modules\Blog\Database\Entities\Article(); $article->title = $this->title; $article->content = $this->content; $article->category_id = $this->categoryId; $article->published = $this->published; $article->author_id = user()->id; $article->created_at = now(); $article->updated_at = now(); transaction($article)->run(); // Сохранение тегов $this->saveTags($article->id); $this->articleId = $article->id; } protected function updateArticle() { $article = rep(\Flute\Modules\Blog\Database\Entities\Article::class) ->findByPK($this->articleId); if (!$article) { throw new \Exception('Article not found'); } $article->title = $this->title; $article->content = $this->content; $article->category_id = $this->categoryId; $article->published = $this->published; $article->updated_at = now(); transaction($article)->run(); // Обновление тегов $this->saveTags($this->articleId); } protected function saveTags($articleId) { // Удаление старых тегов rep(\Flute\Modules\Blog\Database\Entities\ArticleTag::class) ->select() ->where('article_id', $articleId) ->delete(); // Добавление новых тегов foreach ($this->tags as $tagName) { $tag = rep(\Flute\Modules\Blog\Database\Entities\Tag::class) ->select() ->where('name', $tagName) ->fetchOne(); if (!$tag) { $tag = new \Flute\Modules\Blog\Database\Entities\Tag(); $tag->name = $tagName; $tag->slug = Str::slug($tagName); transaction($tag)->run(); } $articleTag = new \Flute\Modules\Blog\Database\Entities\ArticleTag(); $articleTag->article_id = $articleId; $articleTag->tag_id = $tag->id; transaction($articleTag)->run(); } } protected function loadArticle() { $article = rep(\Flute\Modules\Blog\Database\Entities\Article::class) ->findByPK($this->articleId); if ($article) { $this->title = $article->title; $this->content = $article->content; $this->categoryId = $article->category_id; $this->published = $article->published; // Загрузка тегов $this->tags = $article->tags->pluck('name')->toArray(); } } protected function loadCategories() { // Категории загружаются в шаблоне через сервис } public function render() { $categories = app(\Flute\Modules\Blog\Services\CategoryService::class)->getAllCategories(); return $this->view('blog::components.article-form', [ 'article_id' => $this->articleId, 'title' => $this->title, 'content' => $this->content, 'category_id' => $this->categoryId, 'tags' => $this->tags, 'published' => $this->published, 'categories' => $categories, 'is_edit' => $this->articleId !== null ]); } }

Шаблон формы статьи

{{-- resources/views/components/article-form.blade.php --}} <x-card class="article-form-component"> <form class="article-form"> @csrf {{-- Заголовок --}} <x-forms.field class="mb-3"> <x-forms.label for="title" required>{{ __('blog.title') }}</x-forms.label> <x-input name="title" id="title" :value="old('title', $title)" yoyo yoyo:trigger="blur" required /> @error('title') <div class="text-danger">{{ $message }}</div> @enderror </x-forms.field> {{-- Категория --}} <x-forms.field class="mb-3"> <x-forms.label for="category_id">{{ __('blog.category') }}</x-forms.label> <select id="category_id" name="category_id" class="form-select" yoyo yoyo:trigger="change"> <option value="">{{ __('blog.select_category') }}</option> @foreach($categories as $category) <option value="{{ $category->id }}" {{ $category_id == $category->id ? 'selected' : '' }}> {{ $category->name }} </option> @endforeach </select> </x-forms.field> {{-- Теги --}} <x-forms.field class="mb-3"> <x-forms.label>{{ __('blog.tags') }}</x-forms.label> <div class="tags-input"> <div class="tags-list"> @foreach($tags as $index => $tag) <span class="badge bg-primary me-1"> {{ $tag }} <button type="button" class="btn-close btn-close-white ms-1" yoyo:click="removeTag({{ $index }})" aria-label="Remove tag"></button> </span> @endforeach </div> <x-input id="tag-input" type="text" class="mt-2" :placeholder="__('blog.add_tag')" onkeypress="handleTagInput(event)" /> </div> </x-forms.field> {{-- Содержимое --}} <x-forms.field class="mb-3"> <x-forms.label for="content" required>{{ __('blog.content') }}</x-forms.label> <textarea id="content" name="content" class="form-control" rows="10" yoyo yoyo:trigger="blur" required>{{ old('content', $content) }}</textarea> @error('content') <div class="text-danger">{{ $message }}</div> @enderror </x-forms.field> {{-- Опубликовать --}} <x-forms.field class="mb-3"> <div class="form-check"> <input type="checkbox" id="published" name="published" class="form-check-input" {{ $published ? 'checked' : '' }} yoyo yoyo:trigger="change"> <label for="published" class="form-check-label">{{ __('blog.publish_immediately') }}</label> </div> </x-forms.field> {{-- Действия --}} <div class="form-actions"> <button type="submit" class="btn btn-primary" yoyo:click="save()"> <x-icon path="save" /> {{ $is_edit ? __('blog.update_article') : __('blog.create_article') }} </button> <button type="button" class="btn btn-secondary ms-2" onclick="window.history.back()"> {{ __('blog.cancel') }} </button> </div> </form> </x-card> @push('scripts') <script> function handleTagInput(event) { if (event.key === 'Enter') { event.preventDefault(); const tagName = event.target.value.trim(); if (tagName) { // Отправка события для добавления тега htmx.ajax('POST', window.location.href, { values: { action: 'add_tag', tag_name: tagName }, target: '#tag-input', swap: 'none' }); event.target.value = ''; } } } // Инициализация редактора (если используется) document.addEventListener('DOMContentLoaded', function() { if (typeof ClassicEditor !== 'undefined') { ClassicEditor .create(document.querySelector('#content')) .catch(error => { console.error(error); }); } }); </script> @endpush

Компоненты с загрузкой файлов

Компонент загрузки изображений

<?php namespace Flute\Modules\Blog\Components; use Flute\Core\Support\FluteComponent; class ImageUploadComponent extends FluteComponent { public array $images = []; public string $uploadPath = 'uploads/blog/images/'; public array $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; public int $maxFileSize = 5 * 1024 * 1024; // 5MB public int $maxFiles = 10; public function upload($files) { $uploadedFiles = []; foreach ($files as $file) { try { $uploadedFile = $this->processFile($file); $uploadedFiles[] = $uploadedFile; } catch (\Exception $e) { $this->flashMessage($e->getMessage(), 'error'); } } if (!empty($uploadedFiles)) { $this->images = array_merge($this->images, $uploadedFiles); $this->emitEvent('images-uploaded', [ 'uploaded' => $uploadedFiles, 'total' => count($this->images) ]); } } public function removeImage($index) { if (isset($this->images[$index])) { $image = $this->images[$index]; // Удаление файла if (file_exists(public_path($image['path']))) { unlink(public_path($image['path'])); } // Удаление из массива unset($this->images[$index]); $this->images = array_values($this->images); $this->emitEvent('image-removed', [ 'removed' => $image, 'total' => count($this->images) ]); } } protected function processFile($file) { // Валидация типа файла if (!in_array($file['type'], $this->allowedTypes)) { throw new \Exception(__('blog.invalid_file_type')); } // Валидация размера файла if ($file['size'] > $this->maxFileSize) { throw new \Exception(__('blog.file_too_large')); } // Проверка количества файлов if (count($this->images) >= $this->maxFiles) { throw new \Exception(__('blog.too_many_files')); } // Генерация имени файла $extension = pathinfo($file['name'], PATHINFO_EXTENSION); $filename = uniqid('blog_') . '.' . $extension; $fullPath = $this->uploadPath . $filename; // Создание директории $directory = dirname(public_path($fullPath)); if (!is_dir($directory)) { mkdir($directory, 0755, true); } // Перемещение файла if (!move_uploaded_file($file['tmp_name'], public_path($fullPath))) { throw new \Exception(__('blog.upload_failed')); } // Получение размеров изображения $imageInfo = getimagesize(public_path($fullPath)); $dimensions = $imageInfo ? $imageInfo[0] . 'x' . $imageInfo[1] : null; return [ 'name' => $file['name'], 'filename' => $filename, 'path' => $fullPath, 'size' => $file['size'], 'type' => $file['type'], 'dimensions' => $dimensions, 'uploaded_at' => now()->toISOString() ]; } public function render() { return $this->view('blog::components.image-upload', [ 'images' => $this->images, 'max_files' => $this->maxFiles, 'allowed_types' => implode(', ', array_map(function($type) { return str_replace('image/', '.', $type); }, $this->allowedTypes)), 'max_file_size' => $this->formatFileSize($this->maxFileSize) ]); } protected function formatFileSize($bytes) { $units = ['B', 'KB', 'MB', 'GB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= pow(1024, $pow); return round($bytes, 2) . ' ' . $units[$pow]; } }

Шаблон загрузки изображений

{{-- resources/views/components/image-upload.blade.php --}} <div class="image-upload-component"> {{-- Загруженные изображения --}} @if(count($images) > 0) <div class="uploaded-images mb-3"> <h6>{{ __('blog.uploaded_images') }} ({{ count($images) }}/{{ $max_files }})</h6> <div class="images-grid"> @foreach($images as $index => $image) <div class="image-item"> <div class="image-preview"> <img src="{{ asset($image['path']) }}" alt="{{ $image['name'] }}" class="img-thumbnail"> <button type="button" class="btn btn-danger btn-sm remove-btn" yoyo:click="removeImage({{ $index }})" title="{{ __('blog.remove_image') }}"> <x-icon path="times" /> </button> </div> <div class="image-info small text-muted"> <div>{{ Str::limit($image['name'], 20) }}</div> @if($image['dimensions']) <div>{{ $image['dimensions'] }}</div> @endif <div>{{ number_format($image['size'] / 1024, 1) }} KB</div> </div> </div> @endforeach </div> </div> @endif {{-- Зона загрузки --}} <div class="upload-zone" hx-post="{{ route('blog.images.upload') }}" hx-target="#upload-result" hx-swap="innerHTML"> <input type="file" name="images[]" id="image-input" multiple accept="{{ $allowed_types }}" style="display: none;"> <div class="upload-area" onclick="document.getElementById('image-input').click()"> <div class="upload-icon"> <x-icon path="cloud-upload-alt" class="fa-3x text-muted" /> </div> <div class="upload-text"> <h6>{{ __('blog.drop_images_here') }}</h6> <p class="text-muted small"> {{ __('blog.or_click_to_select') }}<br> {{ __('blog.max_files') }}: {{ $max_files }}<br> {{ __('blog.allowed_types') }}: {{ $allowed_types }}<br> {{ __('blog.max_size') }}: {{ $max_file_size }} </p> </div> </div> <div id="upload-result"></div> </div> </div> @push('styles') <style> .upload-zone { border: 2px dashed #dee2e6; border-radius: 0.375rem; padding: 2rem; text-align: center; cursor: pointer; transition: all 0.3s ease; } .upload-zone:hover { border-color: #0d6efd; background-color: #f8f9fa; } .upload-area { pointer-events: none; } .images-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; } .image-item { position: relative; } .image-preview { position: relative; aspect-ratio: 1; overflow: hidden; border-radius: 0.375rem; } .image-preview img { width: 100%; height: 100%; object-fit: cover; } .remove-btn { position: absolute; top: 0.25rem; right: 0.25rem; padding: 0.125rem 0.25rem; border-radius: 50%; width: 1.5rem; height: 1.5rem; display: flex; align-items: center; justify-content: center; } </style> @endpush @push('scripts') <script> // Drag and drop functionality document.addEventListener('DOMContentLoaded', function() { const uploadZone = document.querySelector('.upload-zone'); const fileInput = document.getElementById('image-input'); // Drag and drop events uploadZone.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('dragover'); }); uploadZone.addEventListener('dragleave', function(e) { e.preventDefault(); this.classList.remove('dragover'); }); uploadZone.addEventListener('drop', function(e) { e.preventDefault(); this.classList.remove('dragover'); const files = Array.from(e.dataTransfer.files); handleFiles(files); }); // File input change fileInput.addEventListener('change', function(e) { const files = Array.from(e.target.files); handleFiles(files); }); function handleFiles(files) { // Filter image files const imageFiles = files.filter(file => file.type.startsWith('image/')); if (imageFiles.length === 0) { showNotification('{{ __('blog.no_image_files_selected') }}', 'warning'); return; } // Upload files using HTMX const formData = new FormData(); imageFiles.forEach(file => { formData.append('images[]', file); }); htmx.ajax('POST', '{{ route('blog.images.upload') }}', { body: formData, target: '#upload-result', swap: 'innerHTML' }); } }); // Обработка событий компонента document.addEventListener('images-uploaded', function(event) { const data = event.detail; showNotification(`{{ __('blog.images_uploaded') }}: ${data.uploaded.length}`, 'success'); }); document.addEventListener('image-removed', function(event) { const data = event.detail; showNotification('{{ __('blog.image_removed') }}', 'info'); }); </script> @endpush

Интеграция с модальными окнами

Модальный компонент

<?php namespace Flute\Modules\Blog\Components; use Flute\Core\Support\FluteComponent; class ArticleQuickViewComponent extends FluteComponent { public ?int $articleId = null; protected ?\Flute\Modules\Blog\Database\Entities\Article $article = null; public function mount(int $articleId) { $this->articleId = $articleId; $this->loadArticle(); } public function like() { if (!user()->isLoggedIn()) { $this->flashMessage(__('blog.login_required'), 'warning'); return; } // Логика лайка статьи $this->toggleLike(); } public function addToBookmarks() { if (!user()->isLoggedIn()) { $this->flashMessage(__('blog.login_required'), 'warning'); return; } // Логика добавления в закладки $this->toggleBookmark(); } public function share($platform) { $url = route('blog.show', ['slug' => $this->article->slug]); $title = $this->article->title; $shareUrl = match($platform) { 'twitter' => "https://twitter.com/intent/tweet?url={$url}&text={$title}", 'facebook' => "https://www.facebook.com/sharer/sharer.php?u={$url}", 'telegram' => "https://t.me/share/url?url={$url}&text={$title}", default => $url }; $this->emitEvent('article-shared', [ 'platform' => $platform, 'url' => $shareUrl ]); } protected function loadArticle() { $this->article = rep(\Flute\Modules\Blog\Database\Entities\Article::class) ->select() ->where('id', $this->articleId) ->load('author') ->load('category') ->load('tags') ->fetchOne(); } protected function toggleLike() { // Логика лайка/дизлайка } protected function toggleBookmark() { // Логика закладок } public function render() { if (!$this->article) { return $this->view('blog::components.article-not-found'); } return $this->view('blog::components.article-quick-view', [ 'article' => $this->article ]); } }

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

Производительность

  1. Используйте кеширование для часто запрашиваемых данных
  2. Минифицируйте HTML, CSS и JavaScript
  3. Оптимизируйте изображения перед загрузкой
  4. Используйте ленивую загрузку для больших списков

Безопасность

  1. Валидируйте все входные данные на сервере
  2. Используйте CSRF токены для форм
  3. Проверяйте права доступа перед выполнением действий
  4. Очищайте пользовательский ввод от вредоносного кода

Доступность

  1. Добавьте ARIA-атрибуты для скрин-ридеров
  2. Обеспечьте навигацию с клавиатуры
  3. Добавьте альтернативные тексты для изображений
  4. Используйте семантические теги HTML

Поддерживаемость

  1. Разделяйте логику на небольшие компоненты
  2. Используйте события для связи между компонентами
  3. Документируйте API компонентов
  4. Пишите тесты для критической функциональности

HTMX и Yoyo компоненты позволяют создавать современные, интерактивные веб-приложения с минимальным количеством JavaScript кода, обеспечивая высокую производительность и отличный пользовательский опыт.