docs: add package documentation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
63f274f83a
commit
a91849fb53
10 changed files with 5939 additions and 0 deletions
559
docs/authorization.md
Normal file
559
docs/authorization.md
Normal file
|
|
@ -0,0 +1,559 @@
|
||||||
|
# Authorization
|
||||||
|
|
||||||
|
Integration with Laravel's Gate and Policy system for fine-grained authorization in admin panels.
|
||||||
|
|
||||||
|
## Form Component Authorization
|
||||||
|
|
||||||
|
All form components support authorization props:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::button
|
||||||
|
:can="'publish'"
|
||||||
|
:cannot="'delete'"
|
||||||
|
:canAny="['edit', 'update']"
|
||||||
|
>
|
||||||
|
Publish Post
|
||||||
|
</x-admin::button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authorization Props
|
||||||
|
|
||||||
|
**`can` - Single ability:**
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::button :can="'delete'" :model="$post">
|
||||||
|
Delete
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- Only shown if user can delete the post --}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`cannot` - Inverse check:**
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::input
|
||||||
|
name="status"
|
||||||
|
:cannot="'publish'"
|
||||||
|
:model="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Disabled if user cannot publish --}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`canAny` - Multiple abilities (OR):**
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::button :canAny="['edit', 'update']" :model="$post">
|
||||||
|
Edit Post
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- Shown if user can either edit OR update --}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Policy Integration
|
||||||
|
|
||||||
|
### Defining Policies
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Policies;
|
||||||
|
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
class PostPolicy
|
||||||
|
{
|
||||||
|
public function view(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->workspace_id === $post->workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->id === $post->author_id
|
||||||
|
|| $user->hasRole('editor');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin')
|
||||||
|
&& $user->workspace_id === $post->workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.publish')
|
||||||
|
&& $post->status !== 'archived';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Policies
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
use Mod\Blog\Policies\PostPolicy;
|
||||||
|
|
||||||
|
// In AuthServiceProvider or module Boot class
|
||||||
|
Gate::policy(Post::class, PostPolicy::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Action Gate
|
||||||
|
|
||||||
|
Use the Action Gate system for route-level authorization:
|
||||||
|
|
||||||
|
### Defining Actions
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Controllers;
|
||||||
|
|
||||||
|
use Core\Bouncer\Gate\Attributes\Action;
|
||||||
|
|
||||||
|
class PostController
|
||||||
|
{
|
||||||
|
#[Action(
|
||||||
|
name: 'posts.create',
|
||||||
|
description: 'Create new blog posts',
|
||||||
|
group: 'Content Management'
|
||||||
|
)]
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
// Only accessible to users with 'posts.create' permission
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Action(
|
||||||
|
name: 'posts.publish',
|
||||||
|
description: 'Publish blog posts',
|
||||||
|
group: 'Content Management',
|
||||||
|
dangerous: true
|
||||||
|
)]
|
||||||
|
public function publish(Post $post)
|
||||||
|
{
|
||||||
|
// Marked as dangerous action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Protection
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Bouncer\Gate\ActionGateMiddleware;
|
||||||
|
|
||||||
|
// Protect single route
|
||||||
|
Route::post('/posts', [PostController::class, 'store'])
|
||||||
|
->middleware(['auth', ActionGateMiddleware::class]);
|
||||||
|
|
||||||
|
// Protect route group
|
||||||
|
Route::middleware(['auth', ActionGateMiddleware::class])
|
||||||
|
->group(function () {
|
||||||
|
Route::post('/posts', [PostController::class, 'store']);
|
||||||
|
Route::post('/posts/{post}/publish', [PostController::class, 'publish']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Permissions
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Bouncer\Gate\ActionGateService;
|
||||||
|
|
||||||
|
$gate = app(ActionGateService::class);
|
||||||
|
|
||||||
|
// Check if user can perform action
|
||||||
|
if ($gate->allows('posts.create', auth()->user())) {
|
||||||
|
// User has permission
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check with additional context
|
||||||
|
if ($gate->allows('posts.publish', auth()->user(), $post)) {
|
||||||
|
// User can publish this specific post
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user permissions
|
||||||
|
$permissions = $gate->getUserPermissions(auth()->user());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Menu Authorization
|
||||||
|
|
||||||
|
Restrict menu items by permission:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Admin\Support\MenuItemBuilder;
|
||||||
|
|
||||||
|
MenuItemBuilder::create('Posts')
|
||||||
|
->route('admin.posts.index')
|
||||||
|
->icon('heroicon-o-document-text')
|
||||||
|
->can('posts.view') // Only shown if user can view posts
|
||||||
|
->badge(fn () => Post::pending()->count())
|
||||||
|
->children([
|
||||||
|
MenuItemBuilder::create('All Posts')
|
||||||
|
->route('admin.posts.index'),
|
||||||
|
|
||||||
|
MenuItemBuilder::create('Create Post')
|
||||||
|
->route('admin.posts.create')
|
||||||
|
->can('posts.create'), // Nested permission check
|
||||||
|
|
||||||
|
MenuItemBuilder::create('Categories')
|
||||||
|
->route('admin.categories.index')
|
||||||
|
->canAny(['categories.view', 'categories.edit']),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Livewire Modal Authorization
|
||||||
|
|
||||||
|
Protect Livewire modals with authorization checks:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
|
class PostEditor extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public Post $post;
|
||||||
|
|
||||||
|
public function mount(Post $post)
|
||||||
|
{
|
||||||
|
// Authorize on mount
|
||||||
|
$this->authorize('update', $post);
|
||||||
|
|
||||||
|
$this->post = $post;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
// Authorize action
|
||||||
|
$this->authorize('update', $this->post);
|
||||||
|
|
||||||
|
$this->post->save();
|
||||||
|
|
||||||
|
$this->dispatch('post-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish()
|
||||||
|
{
|
||||||
|
// Custom authorization
|
||||||
|
$this->authorize('publish', $this->post);
|
||||||
|
|
||||||
|
$this->post->update(['status' => 'published']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workspace Scoping
|
||||||
|
|
||||||
|
Automatic workspace isolation with policies:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class PostPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
// User can view posts in their workspace
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
// Enforce workspace boundary
|
||||||
|
return $user->workspace_id === $post->workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
// Workspace check + additional authorization
|
||||||
|
return $user->workspace_id === $post->workspace_id
|
||||||
|
&& ($user->id === $post->author_id || $user->hasRole('editor'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Role-Based Authorization
|
||||||
|
|
||||||
|
### Defining Roles
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
|
||||||
|
// Assign role
|
||||||
|
$user->assignRole('editor');
|
||||||
|
|
||||||
|
// Check role
|
||||||
|
if ($user->hasRole('admin')) {
|
||||||
|
// User is admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check any role
|
||||||
|
if ($user->hasAnyRole(['editor', 'author'])) {
|
||||||
|
// User has at least one role
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all roles
|
||||||
|
if ($user->hasAllRoles(['editor', 'reviewer'])) {
|
||||||
|
// User has both roles
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Policy with Roles
|
||||||
|
|
||||||
|
```php
|
||||||
|
class PostPolicy
|
||||||
|
{
|
||||||
|
public function update(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin')
|
||||||
|
|| ($user->hasRole('editor') && $user->workspace_id === $post->workspace_id)
|
||||||
|
|| ($user->hasRole('author') && $user->id === $post->author_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
// Only admins can delete
|
||||||
|
return $user->hasRole('admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permission-Based Authorization
|
||||||
|
|
||||||
|
### Defining Permissions
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Grant permission
|
||||||
|
$user->givePermission('posts.create');
|
||||||
|
$user->givePermission('posts.publish');
|
||||||
|
|
||||||
|
// Check permission
|
||||||
|
if ($user->hasPermission('posts.publish')) {
|
||||||
|
// User can publish
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check multiple permissions
|
||||||
|
if ($user->hasAllPermissions(['posts.create', 'posts.publish'])) {
|
||||||
|
// User has all permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check any permission
|
||||||
|
if ($user->hasAnyPermission(['posts.edit', 'posts.delete'])) {
|
||||||
|
// User has at least one permission
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Policy with Permissions
|
||||||
|
|
||||||
|
```php
|
||||||
|
class PostPolicy
|
||||||
|
{
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.publish')
|
||||||
|
&& $post->status === 'draft';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Rendering
|
||||||
|
|
||||||
|
### Blade Directives
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@can('create', App\Models\Post::class)
|
||||||
|
<a href="{{ route('posts.create') }}">Create Post</a>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
@cannot('delete', $post)
|
||||||
|
<p>You cannot delete this post</p>
|
||||||
|
@endcannot
|
||||||
|
|
||||||
|
@canany(['edit', 'update'], $post)
|
||||||
|
<a href="{{ route('posts.edit', $post) }}">Edit</a>
|
||||||
|
@endcanany
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Visibility
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::button
|
||||||
|
:can="'publish'"
|
||||||
|
:model="$post"
|
||||||
|
wire:click="publish"
|
||||||
|
>
|
||||||
|
Publish
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- Automatically hidden if user cannot publish --}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Field Disabling
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::input
|
||||||
|
name="slug"
|
||||||
|
:cannot="'edit-slug'"
|
||||||
|
:model="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Disabled if user cannot edit slug --}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authorization Middleware
|
||||||
|
|
||||||
|
### Global Middleware
|
||||||
|
|
||||||
|
```php
|
||||||
|
// app/Http/Kernel.php
|
||||||
|
protected $middlewareGroups = [
|
||||||
|
'web' => [
|
||||||
|
// ...
|
||||||
|
\Core\Bouncer\Gate\ActionGateMiddleware::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Middleware
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Require authentication
|
||||||
|
Route::middleware(['auth'])->group(function () {
|
||||||
|
Route::get('/admin', [AdminController::class, 'index']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Require specific ability
|
||||||
|
Route::middleware(['can:create,App\Models\Post'])->group(function () {
|
||||||
|
Route::get('/posts/create', [PostController::class, 'create']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Authorization
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tests\TestCase;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
|
||||||
|
class AuthorizationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_user_can_view_own_posts(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$post = Post::factory()->create(['author_id' => $user->id]);
|
||||||
|
|
||||||
|
$this->assertTrue($user->can('view', $post));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_cannot_delete_others_posts(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$post = Post::factory()->create(); // Different author
|
||||||
|
|
||||||
|
$this->assertFalse($user->can('delete', $post));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_delete_any_post(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$post = Post::factory()->create();
|
||||||
|
|
||||||
|
$this->assertTrue($admin->can('delete', $post));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_workspace_isolation(): void
|
||||||
|
{
|
||||||
|
$user1 = User::factory()->create(['workspace_id' => 1]);
|
||||||
|
$user2 = User::factory()->create(['workspace_id' => 2]);
|
||||||
|
|
||||||
|
$post = Post::factory()->create(['workspace_id' => 1]);
|
||||||
|
|
||||||
|
$this->assertTrue($user1->can('view', $post));
|
||||||
|
$this->assertFalse($user2->can('view', $post));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Always Check Workspace Boundaries
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - workspace check
|
||||||
|
public function view(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->workspace_id === $post->workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad - no workspace check
|
||||||
|
public function view(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return true; // Data leak!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Policies Over Gates
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - policy
|
||||||
|
$this->authorize('update', $post);
|
||||||
|
|
||||||
|
// ❌ Bad - manual check
|
||||||
|
if (auth()->id() !== $post->author_id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Authorize Early
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - authorize in mount
|
||||||
|
public function mount(Post $post)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $post);
|
||||||
|
$this->post = $post;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad - authorize in action
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$this->authorize('update', $this->post); // Too late!
|
||||||
|
$this->post->save();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Authorization Props
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - declarative authorization --}}
|
||||||
|
<x-admin::button :can="'delete'" :model="$post">
|
||||||
|
Delete
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- ❌ Bad - manual check --}}
|
||||||
|
@if(auth()->user()->can('delete', $post))
|
||||||
|
<x-admin::button>Delete</x-admin::button>
|
||||||
|
@endif
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Form Components →](/packages/admin/forms)
|
||||||
|
- [Admin Menus →](/packages/admin/menus)
|
||||||
|
- [Multi-Tenancy →](/packages/core/tenancy)
|
||||||
784
docs/components-reference.md
Normal file
784
docs/components-reference.md
Normal file
|
|
@ -0,0 +1,784 @@
|
||||||
|
# Components Reference
|
||||||
|
|
||||||
|
Complete API reference for all form components in the Admin package, including prop documentation, validation rules, authorization integration, and accessibility notes.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
All form components in Core PHP:
|
||||||
|
- Wrap Flux UI components with additional features
|
||||||
|
- Support authorization via `canGate` and `canResource` props
|
||||||
|
- Include ARIA accessibility attributes
|
||||||
|
- Work seamlessly with Livewire
|
||||||
|
- Follow consistent naming conventions
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
Text input with various types and authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="Enter title"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier for the input |
|
||||||
|
| `label` | string | `null` | Label text displayed above input |
|
||||||
|
| `helper` | string | `null` | Helper text displayed below input |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource to check ability against |
|
||||||
|
| `instantSave` | bool | `false` | Use `wire:model.live.debounce.500ms` |
|
||||||
|
| `type` | string | `'text'` | Input type (text, email, password, number, etc.) |
|
||||||
|
| `placeholder` | string | `null` | Placeholder text |
|
||||||
|
| `disabled` | bool | `false` | Disable the input |
|
||||||
|
| `readonly` | bool | `false` | Make input read-only |
|
||||||
|
| `required` | bool | `false` | Mark as required |
|
||||||
|
| `min` | number | `null` | Minimum value (for number inputs) |
|
||||||
|
| `max` | number | `null` | Maximum value (for number inputs) |
|
||||||
|
| `maxlength` | number | `null` | Maximum character length |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Input disabled if user cannot update the post --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
label="Title"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Variants
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Text input --}}
|
||||||
|
<x-forms.input id="name" label="Name" type="text" />
|
||||||
|
|
||||||
|
{{-- Email input --}}
|
||||||
|
<x-forms.input id="email" label="Email" type="email" />
|
||||||
|
|
||||||
|
{{-- Password input --}}
|
||||||
|
<x-forms.input id="password" label="Password" type="password" />
|
||||||
|
|
||||||
|
{{-- Number input --}}
|
||||||
|
<x-forms.input id="quantity" label="Quantity" type="number" min="1" max="100" />
|
||||||
|
|
||||||
|
{{-- Date input --}}
|
||||||
|
<x-forms.input id="date" label="Date" type="date" />
|
||||||
|
|
||||||
|
{{-- URL input --}}
|
||||||
|
<x-forms.input id="website" label="Website" type="url" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instant Save Mode
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Saves with 500ms debounce --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="slug"
|
||||||
|
wire:model="slug"
|
||||||
|
label="Slug"
|
||||||
|
instantSave
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
The component automatically:
|
||||||
|
- Associates label with input via `id`
|
||||||
|
- Links error messages with `aria-describedby`
|
||||||
|
- Sets `aria-invalid="true"` when validation fails
|
||||||
|
- Includes helper text in accessible description
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Textarea
|
||||||
|
|
||||||
|
Multi-line text input with authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.textarea
|
||||||
|
id="content"
|
||||||
|
wire:model="content"
|
||||||
|
label="Content"
|
||||||
|
rows="10"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier |
|
||||||
|
| `label` | string | `null` | Label text |
|
||||||
|
| `helper` | string | `null` | Helper text |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `instantSave` | bool | `false` | Use live debounced binding |
|
||||||
|
| `rows` | number | `3` | Number of visible rows |
|
||||||
|
| `placeholder` | string | `null` | Placeholder text |
|
||||||
|
| `disabled` | bool | `false` | Disable the textarea |
|
||||||
|
| `maxlength` | number | `null` | Maximum character length |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.textarea
|
||||||
|
id="bio"
|
||||||
|
wire:model="bio"
|
||||||
|
label="Biography"
|
||||||
|
rows="5"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$profile"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Character Limit
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.textarea
|
||||||
|
id="description"
|
||||||
|
wire:model="description"
|
||||||
|
label="Description"
|
||||||
|
maxlength="500"
|
||||||
|
helper="Maximum 500 characters"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select
|
||||||
|
|
||||||
|
Dropdown select with authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.select
|
||||||
|
id="status"
|
||||||
|
wire:model="status"
|
||||||
|
label="Status"
|
||||||
|
>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
<flux:select.option value="archived">Archived</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier |
|
||||||
|
| `label` | string | `null` | Label text |
|
||||||
|
| `helper` | string | `null` | Helper text |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `instantSave` | bool | `false` | Use live binding |
|
||||||
|
| `placeholder` | string | `null` | Placeholder option text |
|
||||||
|
| `disabled` | bool | `false` | Disable the select |
|
||||||
|
| `multiple` | bool | `false` | Allow multiple selections |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.select
|
||||||
|
id="category"
|
||||||
|
wire:model="category_id"
|
||||||
|
label="Category"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
placeholder="Select a category..."
|
||||||
|
>
|
||||||
|
@foreach($categories as $category)
|
||||||
|
<flux:select.option value="{{ $category->id }}">
|
||||||
|
{{ $category->name }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</x-forms.select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Placeholder
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.select
|
||||||
|
id="country"
|
||||||
|
wire:model="country"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Choose a country..."
|
||||||
|
>
|
||||||
|
<flux:select.option value="us">United States</flux:select.option>
|
||||||
|
<flux:select.option value="uk">United Kingdom</flux:select.option>
|
||||||
|
<flux:select.option value="ca">Canada</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Selection
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.select
|
||||||
|
id="tags"
|
||||||
|
wire:model="selectedTags"
|
||||||
|
label="Tags"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
@foreach($tags as $tag)
|
||||||
|
<flux:select.option value="{{ $tag->id }}">
|
||||||
|
{{ $tag->name }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</x-forms.select>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkbox
|
||||||
|
|
||||||
|
Single checkbox with authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="featured"
|
||||||
|
wire:model="featured"
|
||||||
|
label="Featured Post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier |
|
||||||
|
| `label` | string | `null` | Label text (displayed inline) |
|
||||||
|
| `helper` | string | `null` | Helper text below checkbox |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `instantSave` | bool | `false` | Use live binding |
|
||||||
|
| `disabled` | bool | `false` | Disable the checkbox |
|
||||||
|
| `value` | string | `null` | Checkbox value (for arrays) |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="published"
|
||||||
|
wire:model="published"
|
||||||
|
label="Publish immediately"
|
||||||
|
canGate="publish"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Helper Text
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="newsletter"
|
||||||
|
wire:model="newsletter"
|
||||||
|
label="Subscribe to newsletter"
|
||||||
|
helper="Receive weekly updates about new features"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkbox Group
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<fieldset>
|
||||||
|
<legend class="font-medium mb-2">Notifications</legend>
|
||||||
|
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="notify_email"
|
||||||
|
wire:model="notifications"
|
||||||
|
label="Email notifications"
|
||||||
|
value="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="notify_sms"
|
||||||
|
wire:model="notifications"
|
||||||
|
label="SMS notifications"
|
||||||
|
value="sms"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="notify_push"
|
||||||
|
wire:model="notifications"
|
||||||
|
label="Push notifications"
|
||||||
|
value="push"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toggle
|
||||||
|
|
||||||
|
Switch-style toggle with authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.toggle
|
||||||
|
id="active"
|
||||||
|
wire:model="active"
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier |
|
||||||
|
| `label` | string | `null` | Label text (displayed to the left) |
|
||||||
|
| `helper` | string | `null` | Helper text below toggle |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `instantSave` | bool | `false` | Use live binding |
|
||||||
|
| `disabled` | bool | `false` | Disable the toggle |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.toggle
|
||||||
|
id="is_admin"
|
||||||
|
wire:model="is_admin"
|
||||||
|
label="Administrator"
|
||||||
|
canGate="manageRoles"
|
||||||
|
:canResource="$user"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instant Save
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Toggle that saves immediately --}}
|
||||||
|
<x-forms.toggle
|
||||||
|
id="notifications_enabled"
|
||||||
|
wire:model="notifications_enabled"
|
||||||
|
label="Enable Notifications"
|
||||||
|
instantSave
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Helper
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.toggle
|
||||||
|
id="two_factor"
|
||||||
|
wire:model="two_factor_enabled"
|
||||||
|
label="Two-Factor Authentication"
|
||||||
|
helper="Add an extra layer of security to your account"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button
|
||||||
|
|
||||||
|
Action button with variants and authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.button type="submit">
|
||||||
|
Save Changes
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `variant` | string | `'primary'` | Button style variant |
|
||||||
|
| `type` | string | `'submit'` | Button type (submit, button, reset) |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `disabled` | bool | `false` | Disable the button |
|
||||||
|
| `loading` | bool | `false` | Show loading state |
|
||||||
|
|
||||||
|
### Variants
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Primary (default) --}}
|
||||||
|
<x-forms.button variant="primary">Primary</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Secondary --}}
|
||||||
|
<x-forms.button variant="secondary">Secondary</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Danger --}}
|
||||||
|
<x-forms.button variant="danger">Delete</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Ghost --}}
|
||||||
|
<x-forms.button variant="ghost">Cancel</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Button disabled if user cannot delete --}}
|
||||||
|
<x-forms.button
|
||||||
|
variant="danger"
|
||||||
|
canGate="delete"
|
||||||
|
:canResource="$post"
|
||||||
|
wire:click="delete"
|
||||||
|
>
|
||||||
|
Delete Post
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Loading State
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.button type="submit" wire:loading.attr="disabled">
|
||||||
|
<span wire:loading.remove>Save</span>
|
||||||
|
<span wire:loading>Saving...</span>
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### As Link
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onclick="window.location.href='{{ route('admin.posts') }}'"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authorization Props Reference
|
||||||
|
|
||||||
|
All form components support authorization through consistent props.
|
||||||
|
|
||||||
|
### How Authorization Works
|
||||||
|
|
||||||
|
When `canGate` and `canResource` are provided, the component checks if the authenticated user can perform the specified ability on the resource:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Equivalent PHP check
|
||||||
|
auth()->user()?->can($canGate, $canResource)
|
||||||
|
```
|
||||||
|
|
||||||
|
If the check fails, the component is **disabled** (not hidden).
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `canGate` | string | The ability/gate name to check (e.g., `'update'`, `'delete'`, `'publish'`) |
|
||||||
|
| `canResource` | mixed | The resource to check the ability against (usually a model instance) |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Basic Policy Check:**
|
||||||
|
```blade
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple Components with Same Auth:**
|
||||||
|
```blade
|
||||||
|
@php $canEdit = auth()->user()?->can('update', $post); @endphp
|
||||||
|
|
||||||
|
<x-forms.input id="title" wire:model="title" :disabled="!$canEdit" />
|
||||||
|
<x-forms.textarea id="content" wire:model="content" :disabled="!$canEdit" />
|
||||||
|
<x-forms.button type="submit" :disabled="!$canEdit">Save</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Combining with Blade Directives:**
|
||||||
|
```blade
|
||||||
|
@can('update', $post)
|
||||||
|
<x-forms.input id="title" wire:model="title" />
|
||||||
|
<x-forms.button type="submit">Save</x-forms.button>
|
||||||
|
@else
|
||||||
|
<p>You do not have permission to edit this post.</p>
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
### Defining Policies
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class PostPolicy
|
||||||
|
{
|
||||||
|
public function update(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->id === $post->author_id
|
||||||
|
|| $user->hasRole('editor');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.publish')
|
||||||
|
&& $post->status === 'draft';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility Notes
|
||||||
|
|
||||||
|
### ARIA Attributes
|
||||||
|
|
||||||
|
All components automatically include appropriate ARIA attributes:
|
||||||
|
|
||||||
|
| Attribute | Usage |
|
||||||
|
|-----------|-------|
|
||||||
|
| `aria-labelledby` | Links to label element |
|
||||||
|
| `aria-describedby` | Links to helper text and error messages |
|
||||||
|
| `aria-invalid` | Set to `true` when validation fails |
|
||||||
|
| `aria-required` | Set when field is required |
|
||||||
|
| `aria-disabled` | Set when field is disabled |
|
||||||
|
|
||||||
|
### Label Association
|
||||||
|
|
||||||
|
Labels are automatically associated with inputs via the `id` prop:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.input id="email" label="Email Address" />
|
||||||
|
|
||||||
|
{{-- Renders as: --}}
|
||||||
|
<flux:field>
|
||||||
|
<flux:label for="email">Email Address</flux:label>
|
||||||
|
<flux:input id="email" name="email" />
|
||||||
|
</flux:field>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Announcements
|
||||||
|
|
||||||
|
Validation errors are linked to inputs and announced to screen readers:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Component renders error with aria-describedby link --}}
|
||||||
|
<flux:error name="email" />
|
||||||
|
|
||||||
|
{{-- Screen readers announce: "Email is required" --}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Focus Management
|
||||||
|
|
||||||
|
- Tab order follows visual order
|
||||||
|
- Focus states are clearly visible
|
||||||
|
- Error focus moves to first invalid field
|
||||||
|
|
||||||
|
### Keyboard Support
|
||||||
|
|
||||||
|
| Component | Keyboard Support |
|
||||||
|
|-----------|------------------|
|
||||||
|
| Input | Standard text input |
|
||||||
|
| Textarea | Standard multiline |
|
||||||
|
| Select | Arrow keys, Enter, Escape |
|
||||||
|
| Checkbox | Space to toggle |
|
||||||
|
| Toggle | Space to toggle, Arrow keys |
|
||||||
|
| Button | Enter/Space to activate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Integration
|
||||||
|
|
||||||
|
### Server-Side Validation
|
||||||
|
|
||||||
|
Components automatically display Laravel validation errors:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In Livewire component
|
||||||
|
protected array $rules = [
|
||||||
|
'title' => 'required|max:255',
|
||||||
|
'content' => 'required',
|
||||||
|
'status' => 'required|in:draft,published',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
// Errors automatically shown on components
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Real-Time Validation
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function updated($propertyName): void
|
||||||
|
{
|
||||||
|
$this->validateOnly($propertyName);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Shows validation error as user types --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="email"
|
||||||
|
wire:model.live="email"
|
||||||
|
label="Email"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Error Messages
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected array $messages = [
|
||||||
|
'title.required' => 'Please enter a post title.',
|
||||||
|
'content.required' => 'Post content cannot be empty.',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Form Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
{{-- Title --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="Enter post title"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Slug with instant save --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="slug"
|
||||||
|
wire:model="slug"
|
||||||
|
label="Slug"
|
||||||
|
helper="URL-friendly version of the title"
|
||||||
|
instantSave
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Content --}}
|
||||||
|
<x-forms.textarea
|
||||||
|
id="content"
|
||||||
|
wire:model="content"
|
||||||
|
label="Content"
|
||||||
|
rows="15"
|
||||||
|
placeholder="Write your content here..."
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Category --}}
|
||||||
|
<x-forms.select
|
||||||
|
id="category_id"
|
||||||
|
wire:model="category_id"
|
||||||
|
label="Category"
|
||||||
|
placeholder="Select a category..."
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
@foreach($categories as $category)
|
||||||
|
<flux:select.option value="{{ $category->id }}">
|
||||||
|
{{ $category->name }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
{{-- Status --}}
|
||||||
|
<x-forms.select
|
||||||
|
id="status"
|
||||||
|
wire:model="status"
|
||||||
|
label="Status"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
<flux:select.option value="archived">Archived</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
{{-- Featured toggle --}}
|
||||||
|
<x-forms.toggle
|
||||||
|
id="featured"
|
||||||
|
wire:model="featured"
|
||||||
|
label="Featured Post"
|
||||||
|
helper="Display prominently on the homepage"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Newsletter checkbox --}}
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="notify_subscribers"
|
||||||
|
wire:model="notify_subscribers"
|
||||||
|
label="Notify subscribers"
|
||||||
|
helper="Send email notification when published"
|
||||||
|
canGate="publish"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Actions --}}
|
||||||
|
<div class="flex gap-3 pt-4 border-t">
|
||||||
|
<x-forms.button
|
||||||
|
type="submit"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
<x-forms.button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onclick="window.location.href='{{ route('admin.posts') }}'"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
@can('delete', $post)
|
||||||
|
<x-forms.button
|
||||||
|
variant="danger"
|
||||||
|
type="button"
|
||||||
|
wire:click="delete"
|
||||||
|
wire:confirm="Are you sure you want to delete this post?"
|
||||||
|
class="ml-auto"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</x-forms.button>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Form Components Guide](/packages/admin/forms)
|
||||||
|
- [Authorization](/packages/admin/authorization)
|
||||||
|
- [Creating Admin Panels](/packages/admin/creating-admin-panels)
|
||||||
|
- [Livewire Modals](/packages/admin/modals)
|
||||||
623
docs/components.md
Normal file
623
docs/components.md
Normal file
|
|
@ -0,0 +1,623 @@
|
||||||
|
# Admin Components
|
||||||
|
|
||||||
|
Reusable UI components for building admin panels: cards, tables, stat widgets, and more.
|
||||||
|
|
||||||
|
## Cards
|
||||||
|
|
||||||
|
### Basic Card
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::card>
|
||||||
|
<x-slot:header>
|
||||||
|
<h3>Recent Posts</h3>
|
||||||
|
</x-slot:header>
|
||||||
|
|
||||||
|
<p>Card content goes here...</p>
|
||||||
|
|
||||||
|
<x-slot:footer>
|
||||||
|
<a href="{{ route('posts.index') }}">View All</a>
|
||||||
|
</x-slot:footer>
|
||||||
|
</x-admin::card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card with Actions
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::card>
|
||||||
|
<x-slot:header>
|
||||||
|
<h3>Post Statistics</h3>
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-admin::button size="sm" wire:click="refresh">
|
||||||
|
Refresh
|
||||||
|
</x-admin::button>
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-slot:header>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
{{-- Statistics content --}}
|
||||||
|
</div>
|
||||||
|
</x-admin::card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card Grid
|
||||||
|
|
||||||
|
Display cards in responsive grid:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::card-grid>
|
||||||
|
<x-admin::card>
|
||||||
|
<h4>Total Posts</h4>
|
||||||
|
<p class="text-3xl">1,234</p>
|
||||||
|
</x-admin::card>
|
||||||
|
|
||||||
|
<x-admin::card>
|
||||||
|
<h4>Published</h4>
|
||||||
|
<p class="text-3xl">856</p>
|
||||||
|
</x-admin::card>
|
||||||
|
|
||||||
|
<x-admin::card>
|
||||||
|
<h4>Drafts</h4>
|
||||||
|
<p class="text-3xl">378</p>
|
||||||
|
</x-admin::card>
|
||||||
|
</x-admin::card-grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stat Widgets
|
||||||
|
|
||||||
|
### Simple Stat
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::stat
|
||||||
|
label="Total Revenue"
|
||||||
|
value="£45,231"
|
||||||
|
icon="heroicon-o-currency-pound"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stat with Trend
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::stat
|
||||||
|
label="Active Users"
|
||||||
|
:value="$activeUsers"
|
||||||
|
icon="heroicon-o-users"
|
||||||
|
:trend="$userTrend"
|
||||||
|
trendLabel="vs last month"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trend Indicators:**
|
||||||
|
- Positive number: green up arrow
|
||||||
|
- Negative number: red down arrow
|
||||||
|
- Zero: neutral indicator
|
||||||
|
|
||||||
|
### Stat with Chart
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::stat
|
||||||
|
label="Page Views"
|
||||||
|
:value="$pageViews"
|
||||||
|
icon="heroicon-o-eye"
|
||||||
|
:sparkline="$viewsData"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sparkline Data:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getSparklineData()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
120, 145, 132, 158, 170, 165, 180, 195, 185, 200
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stat Grid
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<x-admin::stat
|
||||||
|
label="Total Posts"
|
||||||
|
:value="$stats['total']"
|
||||||
|
icon="heroicon-o-document-text"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-admin::stat
|
||||||
|
label="Published"
|
||||||
|
:value="$stats['published']"
|
||||||
|
icon="heroicon-o-check-circle"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-admin::stat
|
||||||
|
label="Drafts"
|
||||||
|
:value="$stats['drafts']"
|
||||||
|
icon="heroicon-o-pencil"
|
||||||
|
color="yellow"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-admin::stat
|
||||||
|
label="Archived"
|
||||||
|
:value="$stats['archived']"
|
||||||
|
icon="heroicon-o-archive-box"
|
||||||
|
color="gray"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### Basic Table
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::table>
|
||||||
|
<x-slot:header>
|
||||||
|
<x-admin::table.th>Title</x-admin::table.th>
|
||||||
|
<x-admin::table.th>Author</x-admin::table.th>
|
||||||
|
<x-admin::table.th>Status</x-admin::table.th>
|
||||||
|
<x-admin::table.th>Actions</x-admin::table.th>
|
||||||
|
</x-slot:header>
|
||||||
|
|
||||||
|
@foreach($posts as $post)
|
||||||
|
<x-admin::table.tr>
|
||||||
|
<x-admin::table.td>{{ $post->title }}</x-admin::table.td>
|
||||||
|
<x-admin::table.td>{{ $post->author->name }}</x-admin::table.td>
|
||||||
|
<x-admin::table.td>
|
||||||
|
<x-admin::badge :color="$post->status_color">
|
||||||
|
{{ $post->status }}
|
||||||
|
</x-admin::badge>
|
||||||
|
</x-admin::table.td>
|
||||||
|
<x-admin::table.td>
|
||||||
|
<x-admin::button size="sm" wire:click="edit({{ $post->id }})">
|
||||||
|
Edit
|
||||||
|
</x-admin::button>
|
||||||
|
</x-admin::table.td>
|
||||||
|
</x-admin::table.tr>
|
||||||
|
@endforeach
|
||||||
|
</x-admin::table>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sortable Table
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::table>
|
||||||
|
<x-slot:header>
|
||||||
|
<x-admin::table.th sortable wire:click="sortBy('title')" :active="$sortField === 'title'">
|
||||||
|
Title
|
||||||
|
</x-admin::table.th>
|
||||||
|
<x-admin::table.th sortable wire:click="sortBy('created_at')" :active="$sortField === 'created_at'">
|
||||||
|
Created
|
||||||
|
</x-admin::table.th>
|
||||||
|
</x-slot:header>
|
||||||
|
|
||||||
|
{{-- Table rows --}}
|
||||||
|
</x-admin::table>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Livewire Component:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
class PostsTable extends Component
|
||||||
|
{
|
||||||
|
public $sortField = 'created_at';
|
||||||
|
public $sortDirection = 'desc';
|
||||||
|
|
||||||
|
public function sortBy($field)
|
||||||
|
{
|
||||||
|
if ($this->sortField === $field) {
|
||||||
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortField = $field;
|
||||||
|
$this->sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$posts = Post::orderBy($this->sortField, $this->sortDirection)
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
return view('livewire.posts-table', compact('posts'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table with Bulk Actions
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::table>
|
||||||
|
<x-slot:header>
|
||||||
|
<x-admin::table.th>
|
||||||
|
<x-admin::checkbox wire:model.live="selectAll" />
|
||||||
|
</x-admin::table.th>
|
||||||
|
<x-admin::table.th>Title</x-admin::table.th>
|
||||||
|
<x-admin::table.th>Actions</x-admin::table.th>
|
||||||
|
</x-slot:header>
|
||||||
|
|
||||||
|
@foreach($posts as $post)
|
||||||
|
<x-admin::table.tr>
|
||||||
|
<x-admin::table.td>
|
||||||
|
<x-admin::checkbox wire:model.live="selected" value="{{ $post->id }}" />
|
||||||
|
</x-admin::table.td>
|
||||||
|
<x-admin::table.td>{{ $post->title }}</x-admin::table.td>
|
||||||
|
<x-admin::table.td>...</x-admin::table.td>
|
||||||
|
</x-admin::table.tr>
|
||||||
|
@endforeach
|
||||||
|
</x-admin::table>
|
||||||
|
|
||||||
|
@if(count($selected) > 0)
|
||||||
|
<div class="bulk-actions">
|
||||||
|
<p>{{ count($selected) }} selected</p>
|
||||||
|
<x-admin::button wire:click="bulkPublish">Publish</x-admin::button>
|
||||||
|
<x-admin::button wire:click="bulkDelete" color="red">Delete</x-admin::button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
```
|
||||||
|
|
||||||
|
## Badges
|
||||||
|
|
||||||
|
### Status Badges
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::badge color="green">Published</x-admin::badge>
|
||||||
|
<x-admin::badge color="yellow">Draft</x-admin::badge>
|
||||||
|
<x-admin::badge color="red">Archived</x-admin::badge>
|
||||||
|
<x-admin::badge color="blue">Scheduled</x-admin::badge>
|
||||||
|
<x-admin::badge color="gray">Pending</x-admin::badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge with Dot
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::badge color="green" dot>
|
||||||
|
Active
|
||||||
|
</x-admin::badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge with Icon
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::badge color="blue">
|
||||||
|
<x-slot:icon>
|
||||||
|
<svg>...</svg>
|
||||||
|
</x-slot:icon>
|
||||||
|
Verified
|
||||||
|
</x-admin::badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removable Badge
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::badge
|
||||||
|
color="blue"
|
||||||
|
removable
|
||||||
|
wire:click="removeTag({{ $tag->id }})"
|
||||||
|
>
|
||||||
|
{{ $tag->name }}
|
||||||
|
</x-admin::badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alerts
|
||||||
|
|
||||||
|
### Basic Alert
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::alert type="success">
|
||||||
|
Post published successfully!
|
||||||
|
</x-admin::alert>
|
||||||
|
|
||||||
|
<x-admin::alert type="error">
|
||||||
|
Failed to save post. Please try again.
|
||||||
|
</x-admin::alert>
|
||||||
|
|
||||||
|
<x-admin::alert type="warning">
|
||||||
|
This post has not been reviewed yet.
|
||||||
|
</x-admin::alert>
|
||||||
|
|
||||||
|
<x-admin::alert type="info">
|
||||||
|
You have 3 draft posts.
|
||||||
|
</x-admin::alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dismissible Alert
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::alert type="success" dismissible>
|
||||||
|
Post published successfully!
|
||||||
|
</x-admin::alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alert with Title
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::alert type="warning">
|
||||||
|
<x-slot:title>
|
||||||
|
Pending Review
|
||||||
|
</x-slot:title>
|
||||||
|
This post requires approval before it can be published.
|
||||||
|
</x-admin::alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empty States
|
||||||
|
|
||||||
|
### Basic Empty State
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::empty-state>
|
||||||
|
<x-slot:icon>
|
||||||
|
<svg>...</svg>
|
||||||
|
</x-slot:icon>
|
||||||
|
|
||||||
|
<x-slot:title>
|
||||||
|
No posts yet
|
||||||
|
</x-slot:title>
|
||||||
|
|
||||||
|
<x-slot:description>
|
||||||
|
Get started by creating your first blog post.
|
||||||
|
</x-slot:description>
|
||||||
|
|
||||||
|
<x-slot:action>
|
||||||
|
<x-admin::button wire:click="create">
|
||||||
|
Create Post
|
||||||
|
</x-admin::button>
|
||||||
|
</x-slot:action>
|
||||||
|
</x-admin::empty-state>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Empty State
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@if($posts->isEmpty() && $search)
|
||||||
|
<x-admin::empty-state>
|
||||||
|
<x-slot:title>
|
||||||
|
No results found
|
||||||
|
</x-slot:title>
|
||||||
|
|
||||||
|
<x-slot:description>
|
||||||
|
No posts match your search for "{{ $search }}".
|
||||||
|
</x-slot:description>
|
||||||
|
|
||||||
|
<x-slot:action>
|
||||||
|
<x-admin::button wire:click="clearSearch">
|
||||||
|
Clear Search
|
||||||
|
</x-admin::button>
|
||||||
|
</x-slot:action>
|
||||||
|
</x-admin::empty-state>
|
||||||
|
@endif
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading States
|
||||||
|
|
||||||
|
### Skeleton Loaders
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::skeleton type="card" />
|
||||||
|
<x-admin::skeleton type="table" rows="5" />
|
||||||
|
<x-admin::skeleton type="text" lines="3" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading Spinner
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div wire:loading>
|
||||||
|
<x-admin::spinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div wire:loading.remove>
|
||||||
|
{{-- Content --}}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading Overlay
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div wire:loading.class="opacity-50 pointer-events-none">
|
||||||
|
{{-- Content becomes translucent while loading --}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div wire:loading class="loading-overlay">
|
||||||
|
<x-admin::spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::table>
|
||||||
|
{{-- Table content --}}
|
||||||
|
</x-admin::table>
|
||||||
|
|
||||||
|
{{ $posts->links('admin::pagination') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom Pagination:**
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<nav class="pagination">
|
||||||
|
{{ $posts->appends(request()->query())->links() }}
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modals (See Modals Documentation)
|
||||||
|
|
||||||
|
See [Livewire Modals →](/packages/admin/modals) for full modal documentation.
|
||||||
|
|
||||||
|
## Dropdowns
|
||||||
|
|
||||||
|
### Basic Dropdown
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::dropdown>
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-admin::button>
|
||||||
|
Actions
|
||||||
|
</x-admin::button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
<x-admin::dropdown.item wire:click="edit">
|
||||||
|
Edit
|
||||||
|
</x-admin::dropdown.item>
|
||||||
|
|
||||||
|
<x-admin::dropdown.item wire:click="duplicate">
|
||||||
|
Duplicate
|
||||||
|
</x-admin::dropdown.item>
|
||||||
|
|
||||||
|
<x-admin::dropdown.divider />
|
||||||
|
|
||||||
|
<x-admin::dropdown.item wire:click="delete" color="red">
|
||||||
|
Delete
|
||||||
|
</x-admin::dropdown.item>
|
||||||
|
</x-admin::dropdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dropdown with Icons
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::dropdown>
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button>⋮</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
<x-admin::dropdown.item wire:click="edit">
|
||||||
|
<x-slot:icon>
|
||||||
|
<svg>...</svg>
|
||||||
|
</x-slot:icon>
|
||||||
|
Edit Post
|
||||||
|
</x-admin::dropdown.item>
|
||||||
|
|
||||||
|
<x-admin::dropdown.item wire:click="view">
|
||||||
|
<x-slot:icon>
|
||||||
|
<svg>...</svg>
|
||||||
|
</x-slot:icon>
|
||||||
|
View
|
||||||
|
</x-admin::dropdown.item>
|
||||||
|
</x-admin::dropdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::tabs>
|
||||||
|
<x-admin::tab
|
||||||
|
name="general"
|
||||||
|
label="General"
|
||||||
|
:active="$activeTab === 'general'"
|
||||||
|
wire:click="$set('activeTab', 'general')"
|
||||||
|
>
|
||||||
|
{{-- General settings --}}
|
||||||
|
</x-admin::tab>
|
||||||
|
|
||||||
|
<x-admin::tab
|
||||||
|
name="seo"
|
||||||
|
label="SEO"
|
||||||
|
:active="$activeTab === 'seo'"
|
||||||
|
wire:click="$set('activeTab', 'seo')"
|
||||||
|
>
|
||||||
|
{{-- SEO settings --}}
|
||||||
|
</x-admin::tab>
|
||||||
|
|
||||||
|
<x-admin::tab
|
||||||
|
name="advanced"
|
||||||
|
label="Advanced"
|
||||||
|
:active="$activeTab === 'advanced'"
|
||||||
|
wire:click="$set('activeTab', 'advanced')"
|
||||||
|
>
|
||||||
|
{{-- Advanced settings --}}
|
||||||
|
</x-admin::tab>
|
||||||
|
</x-admin::tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Semantic Components
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - semantic component --}}
|
||||||
|
<x-admin::stat
|
||||||
|
label="Revenue"
|
||||||
|
:value="$revenue"
|
||||||
|
icon="heroicon-o-currency-pound"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- ❌ Bad - manual markup --}}
|
||||||
|
<div class="stat">
|
||||||
|
<p>Revenue</p>
|
||||||
|
<span>{{ $revenue }}</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Consistent Colors
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - use color props --}}
|
||||||
|
<x-admin::badge color="green">Active</x-admin::badge>
|
||||||
|
<x-admin::badge color="red">Inactive</x-admin::badge>
|
||||||
|
|
||||||
|
{{-- ❌ Bad - custom classes --}}
|
||||||
|
<span class="bg-green-500">Active</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Loading States
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - show loading state --}}
|
||||||
|
<div wire:loading>
|
||||||
|
<x-admin::spinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ❌ Bad - no feedback --}}
|
||||||
|
<button wire:click="save">Save</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Empty States
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - helpful empty state --}}
|
||||||
|
@if($posts->isEmpty())
|
||||||
|
<x-admin::empty-state>
|
||||||
|
<x-slot:action>
|
||||||
|
<x-admin::button wire:click="create">
|
||||||
|
Create First Post
|
||||||
|
</x-admin::button>
|
||||||
|
</x-slot:action>
|
||||||
|
</x-admin::empty-state>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ❌ Bad - no guidance --}}
|
||||||
|
@if($posts->isEmpty())
|
||||||
|
<p>No posts</p>
|
||||||
|
@endif
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Components
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ComponentsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_stat_widget_renders(): void
|
||||||
|
{
|
||||||
|
$view = $this->blade('<x-admin::stat label="Users" value="100" />');
|
||||||
|
|
||||||
|
$view->assertSee('Users');
|
||||||
|
$view->assertSee('100');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_badge_renders_with_color(): void
|
||||||
|
{
|
||||||
|
$view = $this->blade('<x-admin::badge color="green">Active</x-admin::badge>');
|
||||||
|
|
||||||
|
$view->assertSee('Active');
|
||||||
|
$view->assertSeeInOrder(['class', 'green']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Form Components →](/packages/admin/forms)
|
||||||
|
- [Livewire Modals →](/packages/admin/modals)
|
||||||
|
- [Authorization →](/packages/admin/authorization)
|
||||||
931
docs/creating-admin-panels.md
Normal file
931
docs/creating-admin-panels.md
Normal file
|
|
@ -0,0 +1,931 @@
|
||||||
|
# Creating Admin Panels
|
||||||
|
|
||||||
|
This guide covers the complete process of creating admin panels in the Core PHP Framework, including menu registration, modal creation, and authorization integration.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Admin panels in Core PHP use:
|
||||||
|
- **AdminMenuProvider** - Interface for menu registration
|
||||||
|
- **Livewire Modals** - Full-page components for admin interfaces
|
||||||
|
- **Authorization Props** - Built-in permission checking on components
|
||||||
|
- **HLCRF Layouts** - Composable layout system
|
||||||
|
|
||||||
|
## Menu Registration with AdminMenuProvider
|
||||||
|
|
||||||
|
### Implementing AdminMenuProvider
|
||||||
|
|
||||||
|
The `AdminMenuProvider` interface allows modules to contribute navigation items to the admin sidebar.
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Events\AdminPanelBooting;
|
||||||
|
use Core\Front\Admin\Concerns\HasMenuPermissions;
|
||||||
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
|
use Core\Front\Admin\AdminMenuRegistry;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class Boot extends ServiceProvider implements AdminMenuProvider
|
||||||
|
{
|
||||||
|
use HasMenuPermissions;
|
||||||
|
|
||||||
|
public static array $listens = [
|
||||||
|
AdminPanelBooting::class => 'onAdminPanel',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
// Register views and routes
|
||||||
|
$event->views('blog', __DIR__.'/View/Blade');
|
||||||
|
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
|
||||||
|
|
||||||
|
// Register menu provider
|
||||||
|
app(AdminMenuRegistry::class)->register($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminMenuItems(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Dashboard item in standalone group
|
||||||
|
[
|
||||||
|
'group' => 'dashboard',
|
||||||
|
'priority' => self::PRIORITY_HIGH,
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Blog Dashboard',
|
||||||
|
'icon' => 'newspaper',
|
||||||
|
'href' => route('admin.blog.dashboard'),
|
||||||
|
'active' => request()->routeIs('admin.blog.dashboard'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Service item with entitlement
|
||||||
|
[
|
||||||
|
'group' => 'services',
|
||||||
|
'priority' => self::PRIORITY_NORMAL,
|
||||||
|
'entitlement' => 'core.srv.blog',
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Blog',
|
||||||
|
'icon' => 'newspaper',
|
||||||
|
'href' => route('admin.blog.posts'),
|
||||||
|
'active' => request()->routeIs('admin.blog.*'),
|
||||||
|
'color' => 'blue',
|
||||||
|
'badge' => Post::draft()->count() ?: null,
|
||||||
|
'children' => [
|
||||||
|
['label' => 'All Posts', 'href' => route('admin.blog.posts'), 'icon' => 'document-text'],
|
||||||
|
['label' => 'Categories', 'href' => route('admin.blog.categories'), 'icon' => 'folder'],
|
||||||
|
['label' => 'Tags', 'href' => route('admin.blog.tags'), 'icon' => 'tag'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Admin-only item
|
||||||
|
[
|
||||||
|
'group' => 'admin',
|
||||||
|
'priority' => self::PRIORITY_LOW,
|
||||||
|
'admin' => true,
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Blog Settings',
|
||||||
|
'icon' => 'gear',
|
||||||
|
'href' => route('admin.blog.settings'),
|
||||||
|
'active' => request()->routeIs('admin.blog.settings'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu Item Structure
|
||||||
|
|
||||||
|
Each item in `adminMenuItems()` follows this structure:
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `group` | string | Menu group: `dashboard`, `workspaces`, `services`, `settings`, `admin` |
|
||||||
|
| `priority` | int | Order within group (use `PRIORITY_*` constants) |
|
||||||
|
| `entitlement` | string | Optional workspace feature code for access |
|
||||||
|
| `permissions` | array | Optional user permission keys required |
|
||||||
|
| `admin` | bool | Requires Hades/admin user |
|
||||||
|
| `item` | Closure | Lazy-evaluated item data |
|
||||||
|
|
||||||
|
### Priority Constants
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
|
|
||||||
|
// Available priority constants
|
||||||
|
AdminMenuProvider::PRIORITY_FIRST // 0-9: System items
|
||||||
|
AdminMenuProvider::PRIORITY_HIGH // 10-19: Primary navigation
|
||||||
|
AdminMenuProvider::PRIORITY_ABOVE_NORMAL // 20-39: Important items
|
||||||
|
AdminMenuProvider::PRIORITY_NORMAL // 40-60: Standard items (default)
|
||||||
|
AdminMenuProvider::PRIORITY_BELOW_NORMAL // 61-79: Less important
|
||||||
|
AdminMenuProvider::PRIORITY_LOW // 80-89: Rarely used
|
||||||
|
AdminMenuProvider::PRIORITY_LAST // 90-99: End items
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu Groups
|
||||||
|
|
||||||
|
| Group | Description | Rendering |
|
||||||
|
|-------|-------------|-----------|
|
||||||
|
| `dashboard` | Primary entry points | Standalone items |
|
||||||
|
| `workspaces` | Workspace management | Grouped dropdown |
|
||||||
|
| `services` | Application services | Standalone items |
|
||||||
|
| `settings` | User/account settings | Grouped dropdown |
|
||||||
|
| `admin` | Platform administration | Grouped dropdown (Hades only) |
|
||||||
|
|
||||||
|
### Using MenuItemBuilder
|
||||||
|
|
||||||
|
For complex menus, use the fluent `MenuItemBuilder`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Admin\Support\MenuItemBuilder;
|
||||||
|
|
||||||
|
public function adminMenuItems(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
MenuItemBuilder::make('Commerce')
|
||||||
|
->icon('shopping-cart')
|
||||||
|
->route('admin.commerce.dashboard')
|
||||||
|
->inServices()
|
||||||
|
->priority(self::PRIORITY_NORMAL)
|
||||||
|
->entitlement('core.srv.commerce')
|
||||||
|
->color('green')
|
||||||
|
->badge('New', 'green')
|
||||||
|
->activeOnRoute('admin.commerce.*')
|
||||||
|
->children([
|
||||||
|
MenuItemBuilder::child('Products', route('admin.commerce.products'))
|
||||||
|
->icon('cube'),
|
||||||
|
MenuItemBuilder::child('Orders', route('admin.commerce.orders'))
|
||||||
|
->icon('receipt')
|
||||||
|
->badge(fn () => Order::pending()->count()),
|
||||||
|
['separator' => true],
|
||||||
|
MenuItemBuilder::child('Settings', route('admin.commerce.settings'))
|
||||||
|
->icon('gear'),
|
||||||
|
])
|
||||||
|
->build(),
|
||||||
|
|
||||||
|
MenuItemBuilder::make('Analytics')
|
||||||
|
->icon('chart-line')
|
||||||
|
->route('admin.analytics.dashboard')
|
||||||
|
->inServices()
|
||||||
|
->entitlement('core.srv.analytics')
|
||||||
|
->adminOnly() // Requires admin user
|
||||||
|
->build(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Checking
|
||||||
|
|
||||||
|
The `HasMenuPermissions` trait provides default permission handling:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Admin\Concerns\HasMenuPermissions;
|
||||||
|
|
||||||
|
class BlogMenuProvider implements AdminMenuProvider
|
||||||
|
{
|
||||||
|
use HasMenuPermissions;
|
||||||
|
|
||||||
|
// Override for custom global permissions
|
||||||
|
public function menuPermissions(): array
|
||||||
|
{
|
||||||
|
return ['blog.view'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override for custom permission logic
|
||||||
|
public function canViewMenu(?object $user, ?object $workspace): bool
|
||||||
|
{
|
||||||
|
if ($user === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom logic
|
||||||
|
return $user->hasRole('editor') || $user->isHades();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Livewire Modals
|
||||||
|
|
||||||
|
Livewire modals are full-page components that provide seamless admin interfaces.
|
||||||
|
|
||||||
|
### Basic Modal Structure
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
#[Title('Edit Post')]
|
||||||
|
#[Layout('admin::layouts.app')]
|
||||||
|
class PostEditor extends Component
|
||||||
|
{
|
||||||
|
public ?Post $post = null;
|
||||||
|
public string $title = '';
|
||||||
|
public string $content = '';
|
||||||
|
public string $status = 'draft';
|
||||||
|
|
||||||
|
protected array $rules = [
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'content' => 'required|string',
|
||||||
|
'status' => 'required|in:draft,published,archived',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(?Post $post = null): void
|
||||||
|
{
|
||||||
|
$this->post = $post;
|
||||||
|
|
||||||
|
if ($post) {
|
||||||
|
$this->title = $post->title;
|
||||||
|
$this->content = $post->content;
|
||||||
|
$this->status = $post->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate();
|
||||||
|
|
||||||
|
if ($this->post) {
|
||||||
|
$this->post->update($validated);
|
||||||
|
$message = 'Post updated successfully.';
|
||||||
|
} else {
|
||||||
|
Post::create($validated);
|
||||||
|
$message = 'Post created successfully.';
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->flash('success', $message);
|
||||||
|
$this->redirect(route('admin.blog.posts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('blog::admin.post-editor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal View with HLCRF
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- resources/views/admin/post-editor.blade.php --}}
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold">
|
||||||
|
{{ $post ? 'Edit Post' : 'Create Post' }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<a href="{{ route('admin.blog.posts') }}" class="btn-ghost">
|
||||||
|
<x-icon name="x" class="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::header>
|
||||||
|
|
||||||
|
<x-hlcrf::content>
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
label="Title"
|
||||||
|
wire:model="title"
|
||||||
|
placeholder="Enter post title"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.textarea
|
||||||
|
id="content"
|
||||||
|
label="Content"
|
||||||
|
wire:model="content"
|
||||||
|
rows="15"
|
||||||
|
placeholder="Write your content here..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.select
|
||||||
|
id="status"
|
||||||
|
label="Status"
|
||||||
|
wire:model="status"
|
||||||
|
>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
<flux:select.option value="archived">Archived</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<x-forms.button type="submit">
|
||||||
|
{{ $post ? 'Update' : 'Create' }} Post
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
<x-forms.button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onclick="window.location.href='{{ route('admin.blog.posts') }}'"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</x-forms.button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</x-hlcrf::content>
|
||||||
|
|
||||||
|
<x-hlcrf::right>
|
||||||
|
<div class="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<h3 class="font-medium mb-2">Publishing Tips</h3>
|
||||||
|
<ul class="text-sm text-gray-600 space-y-1">
|
||||||
|
<li>Use descriptive titles</li>
|
||||||
|
<li>Save as draft first</li>
|
||||||
|
<li>Preview before publishing</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::right>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal with Authorization
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class PostEditor extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public Post $post;
|
||||||
|
|
||||||
|
public function mount(Post $post): void
|
||||||
|
{
|
||||||
|
// Authorize on mount
|
||||||
|
$this->authorize('update', $post);
|
||||||
|
|
||||||
|
$this->post = $post;
|
||||||
|
// ... load data
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
// Re-authorize on save
|
||||||
|
$this->authorize('update', $this->post);
|
||||||
|
|
||||||
|
$this->post->update([...]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(): void
|
||||||
|
{
|
||||||
|
// Different authorization for publish
|
||||||
|
$this->authorize('publish', $this->post);
|
||||||
|
|
||||||
|
$this->post->update(['status' => 'published']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $this->post);
|
||||||
|
|
||||||
|
$this->post->delete();
|
||||||
|
$this->redirect(route('admin.blog.posts'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal with File Uploads
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
|
class MediaUploader extends Component
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
public $image;
|
||||||
|
public string $altText = '';
|
||||||
|
|
||||||
|
protected array $rules = [
|
||||||
|
'image' => 'required|image|max:5120', // 5MB max
|
||||||
|
'altText' => 'required|string|max:255',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function upload(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$path = $this->image->store('media', 'public');
|
||||||
|
|
||||||
|
Media::create([
|
||||||
|
'path' => $path,
|
||||||
|
'alt_text' => $this->altText,
|
||||||
|
'mime_type' => $this->image->getMimeType(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->dispatch('media-uploaded');
|
||||||
|
$this->reset(['image', 'altText']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authorization Integration
|
||||||
|
|
||||||
|
### Form Component Authorization Props
|
||||||
|
|
||||||
|
All form components support authorization via `canGate` and `canResource` props:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Button disabled if user cannot update post --}}
|
||||||
|
<x-forms.button
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Input disabled if user cannot update --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
label="Title"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Textarea with authorization --}}
|
||||||
|
<x-forms.textarea
|
||||||
|
id="content"
|
||||||
|
wire:model="content"
|
||||||
|
label="Content"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Select with authorization --}}
|
||||||
|
<x-forms.select
|
||||||
|
id="status"
|
||||||
|
wire:model="status"
|
||||||
|
label="Status"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
{{-- Toggle with authorization --}}
|
||||||
|
<x-forms.toggle
|
||||||
|
id="featured"
|
||||||
|
wire:model="featured"
|
||||||
|
label="Featured"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blade Conditional Rendering
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Show only if user can create --}}
|
||||||
|
@can('create', App\Models\Post::class)
|
||||||
|
<a href="{{ route('admin.blog.posts.create') }}">New Post</a>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
{{-- Show if user can edit OR delete --}}
|
||||||
|
@canany(['update', 'delete'], $post)
|
||||||
|
<div class="actions">
|
||||||
|
@can('update', $post)
|
||||||
|
<a href="{{ route('admin.blog.posts.edit', $post) }}">Edit</a>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
@can('delete', $post)
|
||||||
|
<button wire:click="delete">Delete</button>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
@endcanany
|
||||||
|
|
||||||
|
{{-- Show message if cannot edit --}}
|
||||||
|
@cannot('update', $post)
|
||||||
|
<p class="text-gray-500">You cannot edit this post.</p>
|
||||||
|
@endcannot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating Policies
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Policies;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Models\User;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
class PostPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check workspace boundary for all actions.
|
||||||
|
*/
|
||||||
|
public function before(User $user, string $ability, mixed $model = null): ?bool
|
||||||
|
{
|
||||||
|
// Admins bypass all checks
|
||||||
|
if ($user->isHades()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce workspace isolation
|
||||||
|
if ($model instanceof Post && $user->workspace_id !== $model->workspace_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // Continue to specific method
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.view');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.view');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.edit')
|
||||||
|
|| $user->id === $post->author_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin')
|
||||||
|
|| ($user->hasPermission('posts.delete') && $user->id === $post->author_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.publish')
|
||||||
|
&& $post->status !== 'archived';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Module Example
|
||||||
|
|
||||||
|
Here is a complete example of an admin module with menus, modals, and authorization.
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Mod/Blog/
|
||||||
|
├── Boot.php
|
||||||
|
├── Models/
|
||||||
|
│ └── Post.php
|
||||||
|
├── Policies/
|
||||||
|
│ └── PostPolicy.php
|
||||||
|
├── View/
|
||||||
|
│ ├── Blade/
|
||||||
|
│ │ └── admin/
|
||||||
|
│ │ ├── posts-list.blade.php
|
||||||
|
│ │ └── post-editor.blade.php
|
||||||
|
│ └── Modal/
|
||||||
|
│ └── Admin/
|
||||||
|
│ ├── PostsList.php
|
||||||
|
│ └── PostEditor.php
|
||||||
|
└── Routes/
|
||||||
|
└── admin.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### Boot.php
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Events\AdminPanelBooting;
|
||||||
|
use Core\Front\Admin\AdminMenuRegistry;
|
||||||
|
use Core\Front\Admin\Concerns\HasMenuPermissions;
|
||||||
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
use Mod\Blog\Policies\PostPolicy;
|
||||||
|
|
||||||
|
class Boot extends ServiceProvider implements AdminMenuProvider
|
||||||
|
{
|
||||||
|
use HasMenuPermissions;
|
||||||
|
|
||||||
|
public static array $listens = [
|
||||||
|
AdminPanelBooting::class => 'onAdminPanel',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Register policy
|
||||||
|
Gate::policy(Post::class, PostPolicy::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
// Views
|
||||||
|
$event->views('blog', __DIR__.'/View/Blade');
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
|
||||||
|
|
||||||
|
// Menu
|
||||||
|
app(AdminMenuRegistry::class)->register($this);
|
||||||
|
|
||||||
|
// Livewire components
|
||||||
|
$event->livewire('blog.admin.posts-list', View\Modal\Admin\PostsList::class);
|
||||||
|
$event->livewire('blog.admin.post-editor', View\Modal\Admin\PostEditor::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminMenuItems(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'group' => 'services',
|
||||||
|
'priority' => self::PRIORITY_NORMAL,
|
||||||
|
'entitlement' => 'core.srv.blog',
|
||||||
|
'permissions' => ['posts.view'],
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Blog',
|
||||||
|
'icon' => 'newspaper',
|
||||||
|
'href' => route('admin.blog.posts'),
|
||||||
|
'active' => request()->routeIs('admin.blog.*'),
|
||||||
|
'color' => 'blue',
|
||||||
|
'badge' => $this->getDraftCount(),
|
||||||
|
'children' => [
|
||||||
|
[
|
||||||
|
'label' => 'All Posts',
|
||||||
|
'href' => route('admin.blog.posts'),
|
||||||
|
'icon' => 'document-text',
|
||||||
|
'active' => request()->routeIs('admin.blog.posts'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Create Post',
|
||||||
|
'href' => route('admin.blog.posts.create'),
|
||||||
|
'icon' => 'plus',
|
||||||
|
'active' => request()->routeIs('admin.blog.posts.create'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDraftCount(): ?int
|
||||||
|
{
|
||||||
|
$count = Post::draft()->count();
|
||||||
|
return $count > 0 ? $count : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routes/admin.php
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Mod\Blog\View\Modal\Admin\PostEditor;
|
||||||
|
use Mod\Blog\View\Modal\Admin\PostsList;
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'admin'])
|
||||||
|
->prefix('admin/blog')
|
||||||
|
->name('admin.blog.')
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/posts', PostsList::class)->name('posts');
|
||||||
|
Route::get('/posts/create', PostEditor::class)->name('posts.create');
|
||||||
|
Route::get('/posts/{post}/edit', PostEditor::class)->name('posts.edit');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### View/Modal/Admin/PostsList.php
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
#[Title('Blog Posts')]
|
||||||
|
#[Layout('admin::layouts.app')]
|
||||||
|
class PostsList extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
public string $status = '';
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function posts()
|
||||||
|
{
|
||||||
|
return Post::query()
|
||||||
|
->when($this->search, fn ($q) => $q->where('title', 'like', "%{$this->search}%"))
|
||||||
|
->when($this->status, fn ($q) => $q->where('status', $this->status))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $postId): void
|
||||||
|
{
|
||||||
|
$post = Post::findOrFail($postId);
|
||||||
|
|
||||||
|
$this->authorize('delete', $post);
|
||||||
|
|
||||||
|
$post->delete();
|
||||||
|
|
||||||
|
session()->flash('success', 'Post deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('blog::admin.posts-list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### View/Blade/admin/posts-list.blade.php
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold">Blog Posts</h1>
|
||||||
|
|
||||||
|
@can('create', \Mod\Blog\Models\Post::class)
|
||||||
|
<a href="{{ route('admin.blog.posts.create') }}" class="btn-primary">
|
||||||
|
<x-icon name="plus" class="w-4 h-4 mr-2" />
|
||||||
|
New Post
|
||||||
|
</a>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::header>
|
||||||
|
|
||||||
|
<x-hlcrf::content>
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="mb-6 flex gap-4">
|
||||||
|
<x-forms.input
|
||||||
|
id="search"
|
||||||
|
wire:model.live.debounce.300ms="search"
|
||||||
|
placeholder="Search posts..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.select id="status" wire:model.live="status">
|
||||||
|
<flux:select.option value="">All Statuses</flux:select.option>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Posts table --}}
|
||||||
|
<div class="bg-white rounded-lg shadow">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@forelse($this->posts as $post)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4">{{ $post->title }}</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="badge badge-{{ $post->status === 'published' ? 'green' : 'gray' }}">
|
||||||
|
{{ ucfirst($post->status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">{{ $post->created_at->format('M d, Y') }}</td>
|
||||||
|
<td class="px-6 py-4 text-right space-x-2">
|
||||||
|
@can('update', $post)
|
||||||
|
<a href="{{ route('admin.blog.posts.edit', $post) }}" class="text-blue-600 hover:text-blue-800">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
@can('delete', $post)
|
||||||
|
<button
|
||||||
|
wire:click="delete({{ $post->id }})"
|
||||||
|
wire:confirm="Delete this post?"
|
||||||
|
class="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
@endcan
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-12 text-center text-gray-500">
|
||||||
|
No posts found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Pagination --}}
|
||||||
|
<div class="mt-4">
|
||||||
|
{{ $this->posts->links() }}
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::content>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Always Use Entitlements for Services
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Menu item requires workspace entitlement
|
||||||
|
[
|
||||||
|
'group' => 'services',
|
||||||
|
'entitlement' => 'core.srv.blog', // Required
|
||||||
|
'item' => fn () => [...],
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Authorize Early in Modals
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function mount(Post $post): void
|
||||||
|
{
|
||||||
|
$this->authorize('update', $post); // Fail fast
|
||||||
|
$this->post = $post;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Form Component Authorization Props
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Declarative authorization --}}
|
||||||
|
<x-forms.button canGate="update" :canResource="$post">
|
||||||
|
Save
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Not manual checks --}}
|
||||||
|
@if(auth()->user()->can('update', $post))
|
||||||
|
<button>Save</button>
|
||||||
|
@endif
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Keep Menu Items Lazy
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Item closure is only evaluated when rendered
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Posts',
|
||||||
|
'badge' => Post::draft()->count(), // Computed at render time
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Use HLCRF for Consistent Layouts
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Always use HLCRF for admin views --}}
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>...</x-hlcrf::header>
|
||||||
|
<x-hlcrf::content>...</x-hlcrf::content>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Admin Menus](/packages/admin/menus)
|
||||||
|
- [Livewire Modals](/packages/admin/modals)
|
||||||
|
- [Form Components](/packages/admin/forms)
|
||||||
|
- [Authorization](/packages/admin/authorization)
|
||||||
|
- [HLCRF Layouts](/packages/admin/hlcrf-deep-dive)
|
||||||
627
docs/forms.md
Normal file
627
docs/forms.md
Normal file
|
|
@ -0,0 +1,627 @@
|
||||||
|
# Form Components
|
||||||
|
|
||||||
|
The Admin package provides a comprehensive set of form components with consistent styling, validation, and authorization support.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
All form components:
|
||||||
|
- Follow consistent design patterns
|
||||||
|
- Support Laravel validation
|
||||||
|
- Include accessibility attributes (ARIA)
|
||||||
|
- Work with Livewire
|
||||||
|
- Support authorization props
|
||||||
|
|
||||||
|
## Form Group
|
||||||
|
|
||||||
|
Wrapper component for labels, inputs, and validation errors:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::form-group
|
||||||
|
label="Post Title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
help="Enter a descriptive title for your post"
|
||||||
|
>
|
||||||
|
<x-admin::input
|
||||||
|
name="title"
|
||||||
|
:value="old('title', $post->title)"
|
||||||
|
placeholder="My Amazing Post"
|
||||||
|
/>
|
||||||
|
</x-admin::form-group>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `label` (string) - Field label
|
||||||
|
- `name` (string) - Field name for validation errors
|
||||||
|
- `required` (bool) - Show required indicator
|
||||||
|
- `help` (string) - Help text below field
|
||||||
|
- `error` (string) - Manual error message
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
Text input with various types:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Text input --}}
|
||||||
|
<x-admin::input
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter title"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Email input --}}
|
||||||
|
<x-admin::input
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Password input --}}
|
||||||
|
<x-admin::input
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Number input --}}
|
||||||
|
<x-admin::input
|
||||||
|
name="quantity"
|
||||||
|
label="Quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Date input --}}
|
||||||
|
<x-admin::input
|
||||||
|
name="published_at"
|
||||||
|
label="Publish Date"
|
||||||
|
type="date"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `name` (string, required) - Input name
|
||||||
|
- `label` (string) - Label text
|
||||||
|
- `type` (string) - Input type (text, email, password, number, date, etc.)
|
||||||
|
- `value` (string) - Input value
|
||||||
|
- `placeholder` (string) - Placeholder text
|
||||||
|
- `required` (bool) - Required field
|
||||||
|
- `disabled` (bool) - Disabled state
|
||||||
|
- `readonly` (bool) - Read-only state
|
||||||
|
- `min` / `max` (number) - Min/max for number inputs
|
||||||
|
|
||||||
|
## Textarea
|
||||||
|
|
||||||
|
Multi-line text input:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::textarea
|
||||||
|
name="content"
|
||||||
|
label="Post Content"
|
||||||
|
rows="10"
|
||||||
|
placeholder="Write your content here..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- With character counter --}}
|
||||||
|
<x-admin::textarea
|
||||||
|
name="description"
|
||||||
|
label="Description"
|
||||||
|
maxlength="500"
|
||||||
|
rows="5"
|
||||||
|
show-counter
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `name` (string, required) - Textarea name
|
||||||
|
- `label` (string) - Label text
|
||||||
|
- `rows` (number) - Number of rows (default: 5)
|
||||||
|
- `cols` (number) - Number of columns
|
||||||
|
- `placeholder` (string) - Placeholder text
|
||||||
|
- `maxlength` (number) - Maximum character length
|
||||||
|
- `show-counter` (bool) - Show character counter
|
||||||
|
- `required` (bool) - Required field
|
||||||
|
|
||||||
|
## Select
|
||||||
|
|
||||||
|
Dropdown select:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Simple select --}}
|
||||||
|
<x-admin::select
|
||||||
|
name="status"
|
||||||
|
label="Status"
|
||||||
|
:options="[
|
||||||
|
'draft' => 'Draft',
|
||||||
|
'published' => 'Published',
|
||||||
|
'archived' => 'Archived',
|
||||||
|
]"
|
||||||
|
:value="$post->status"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- With placeholder --}}
|
||||||
|
<x-admin::select
|
||||||
|
name="category_id"
|
||||||
|
label="Category"
|
||||||
|
:options="$categories"
|
||||||
|
placeholder="Select a category..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Multiple select --}}
|
||||||
|
<x-admin::select
|
||||||
|
name="tags[]"
|
||||||
|
label="Tags"
|
||||||
|
:options="$tags"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Grouped options --}}
|
||||||
|
<x-admin::select
|
||||||
|
name="location"
|
||||||
|
label="Location"
|
||||||
|
:options="[
|
||||||
|
'UK' => [
|
||||||
|
'london' => 'London',
|
||||||
|
'manchester' => 'Manchester',
|
||||||
|
],
|
||||||
|
'US' => [
|
||||||
|
'ny' => 'New York',
|
||||||
|
'la' => 'Los Angeles',
|
||||||
|
],
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `name` (string, required) - Select name
|
||||||
|
- `label` (string) - Label text
|
||||||
|
- `options` (array, required) - Options array
|
||||||
|
- `value` (mixed) - Selected value(s)
|
||||||
|
- `placeholder` (string) - Placeholder option
|
||||||
|
- `multiple` (bool) - Allow multiple selections
|
||||||
|
- `required` (bool) - Required field
|
||||||
|
- `disabled` (bool) - Disabled state
|
||||||
|
|
||||||
|
## Checkbox
|
||||||
|
|
||||||
|
Single checkbox:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::checkbox
|
||||||
|
name="published"
|
||||||
|
label="Publish immediately"
|
||||||
|
:checked="$post->published"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- With description --}}
|
||||||
|
<x-admin::checkbox
|
||||||
|
name="featured"
|
||||||
|
label="Featured Post"
|
||||||
|
description="Display this post prominently on the homepage"
|
||||||
|
:checked="$post->featured"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Group of checkboxes --}}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Permissions</legend>
|
||||||
|
|
||||||
|
<x-admin::checkbox
|
||||||
|
name="permissions[]"
|
||||||
|
label="Create Posts"
|
||||||
|
value="posts.create"
|
||||||
|
:checked="in_array('posts.create', $user->permissions)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-admin::checkbox
|
||||||
|
name="permissions[]"
|
||||||
|
label="Edit Posts"
|
||||||
|
value="posts.edit"
|
||||||
|
:checked="in_array('posts.edit', $user->permissions)"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `name` (string, required) - Checkbox name
|
||||||
|
- `label` (string) - Label text
|
||||||
|
- `value` (string) - Checkbox value
|
||||||
|
- `checked` (bool) - Checked state
|
||||||
|
- `description` (string) - Help text below checkbox
|
||||||
|
- `disabled` (bool) - Disabled state
|
||||||
|
|
||||||
|
## Toggle
|
||||||
|
|
||||||
|
Switch-style toggle:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::toggle
|
||||||
|
name="active"
|
||||||
|
label="Active"
|
||||||
|
:checked="$user->active"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- With colors --}}
|
||||||
|
<x-admin::toggle
|
||||||
|
name="notifications_enabled"
|
||||||
|
label="Email Notifications"
|
||||||
|
description="Receive email updates about new posts"
|
||||||
|
:checked="$user->notifications_enabled"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `name` (string, required) - Toggle name
|
||||||
|
- `label` (string) - Label text
|
||||||
|
- `checked` (bool) - Checked state
|
||||||
|
- `description` (string) - Help text
|
||||||
|
- `color` (string) - Toggle color (green, blue, red)
|
||||||
|
- `disabled` (bool) - Disabled state
|
||||||
|
|
||||||
|
## Button
|
||||||
|
|
||||||
|
Action buttons with variants:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Primary button --}}
|
||||||
|
<x-admin::button type="submit">
|
||||||
|
Save Changes
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- Secondary button --}}
|
||||||
|
<x-admin::button variant="secondary" href="{{ route('admin.posts.index') }}">
|
||||||
|
Cancel
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- Danger button --}}
|
||||||
|
<x-admin::button
|
||||||
|
variant="danger"
|
||||||
|
wire:click="delete"
|
||||||
|
wire:confirm="Are you sure?"
|
||||||
|
>
|
||||||
|
Delete Post
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- Ghost button --}}
|
||||||
|
<x-admin::button variant="ghost">
|
||||||
|
Reset
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- Icon button --}}
|
||||||
|
<x-admin::button variant="icon" title="Edit">
|
||||||
|
<x-icon name="pencil" />
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- Loading state --}}
|
||||||
|
<x-admin::button :loading="$isLoading">
|
||||||
|
<span wire:loading.remove>Save</span>
|
||||||
|
<span wire:loading>Saving...</span>
|
||||||
|
</x-admin::button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `type` (string) - Button type (button, submit, reset)
|
||||||
|
- `variant` (string) - Style variant (primary, secondary, danger, ghost, icon)
|
||||||
|
- `href` (string) - Link URL (renders as `<a>`)
|
||||||
|
- `loading` (bool) - Show loading state
|
||||||
|
- `disabled` (bool) - Disabled state
|
||||||
|
- `size` (string) - Size (sm, md, lg)
|
||||||
|
|
||||||
|
## Authorization Props
|
||||||
|
|
||||||
|
All form components support authorization attributes:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::button
|
||||||
|
can="posts.create"
|
||||||
|
:can-arguments="[$post]"
|
||||||
|
>
|
||||||
|
Create Post
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
<x-admin::input
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
readonly-unless="posts.edit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-admin::button
|
||||||
|
variant="danger"
|
||||||
|
hidden-unless="posts.delete"
|
||||||
|
wire:click="delete"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</x-admin::button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authorization Props:**
|
||||||
|
- `can` (string) - Gate/policy check
|
||||||
|
- `can-arguments` (array) - Arguments for gate check
|
||||||
|
- `cannot` (string) - Inverse of `can`
|
||||||
|
- `hidden-unless` (string) - Hide element unless authorized
|
||||||
|
- `readonly-unless` (string) - Make readonly unless authorized
|
||||||
|
- `disabled-unless` (string) - Disable unless authorized
|
||||||
|
|
||||||
|
[Learn more about Authorization →](/packages/admin/authorization)
|
||||||
|
|
||||||
|
## Livewire Integration
|
||||||
|
|
||||||
|
All components work seamlessly with Livewire:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<form wire:submit="save">
|
||||||
|
<x-admin::input
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
wire:model="title"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-admin::textarea
|
||||||
|
name="content"
|
||||||
|
label="Content"
|
||||||
|
wire:model.defer="content"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-admin::select
|
||||||
|
name="status"
|
||||||
|
label="Status"
|
||||||
|
:options="['draft' => 'Draft', 'published' => 'Published']"
|
||||||
|
wire:model="status"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-admin::button type="submit" :loading="$isSaving">
|
||||||
|
Save Post
|
||||||
|
</x-admin::button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Real-Time Validation
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::input
|
||||||
|
name="slug"
|
||||||
|
label="Slug"
|
||||||
|
wire:model.live="slug"
|
||||||
|
wire:loading.class="opacity-50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@error('slug')
|
||||||
|
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounced Input
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::input
|
||||||
|
name="search"
|
||||||
|
label="Search Posts"
|
||||||
|
wire:model.live.debounce.500ms="search"
|
||||||
|
placeholder="Type to search..."
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Components automatically show validation errors:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Controller validation --}}
|
||||||
|
$request->validate([
|
||||||
|
'title' => 'required|max:255',
|
||||||
|
'content' => 'required',
|
||||||
|
'status' => 'required|in:draft,published',
|
||||||
|
]);
|
||||||
|
|
||||||
|
{{-- Blade template --}}
|
||||||
|
<x-admin::form-group label="Title" name="title" required>
|
||||||
|
<x-admin::input name="title" :value="old('title')" />
|
||||||
|
</x-admin::form-group>
|
||||||
|
{{-- Validation errors automatically displayed --}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Error Messages
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::form-group
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
:error="$errors->first('email')"
|
||||||
|
>
|
||||||
|
<x-admin::input name="email" type="email" />
|
||||||
|
</x-admin::form-group>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Form Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<form method="POST" action="{{ route('admin.posts.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{-- Title --}}
|
||||||
|
<x-admin::form-group label="Title" name="title" required>
|
||||||
|
<x-admin::input
|
||||||
|
name="title"
|
||||||
|
:value="old('title', $post->title)"
|
||||||
|
placeholder="Enter post title"
|
||||||
|
maxlength="255"
|
||||||
|
/>
|
||||||
|
</x-admin::form-group>
|
||||||
|
|
||||||
|
{{-- Slug --}}
|
||||||
|
<x-admin::form-group label="Slug" name="slug" required>
|
||||||
|
<x-admin::input
|
||||||
|
name="slug"
|
||||||
|
:value="old('slug', $post->slug)"
|
||||||
|
placeholder="post-slug"
|
||||||
|
/>
|
||||||
|
</x-admin::form-group>
|
||||||
|
|
||||||
|
{{-- Content --}}
|
||||||
|
<x-admin::form-group label="Content" name="content" required>
|
||||||
|
<x-admin::textarea
|
||||||
|
name="content"
|
||||||
|
:value="old('content', $post->content)"
|
||||||
|
rows="15"
|
||||||
|
placeholder="Write your post content..."
|
||||||
|
/>
|
||||||
|
</x-admin::form-group>
|
||||||
|
|
||||||
|
{{-- Status --}}
|
||||||
|
<x-admin::form-group label="Status" name="status" required>
|
||||||
|
<x-admin::select
|
||||||
|
name="status"
|
||||||
|
:options="[
|
||||||
|
'draft' => 'Draft',
|
||||||
|
'published' => 'Published',
|
||||||
|
'archived' => 'Archived',
|
||||||
|
]"
|
||||||
|
:value="old('status', $post->status)"
|
||||||
|
/>
|
||||||
|
</x-admin::form-group>
|
||||||
|
|
||||||
|
{{-- Category --}}
|
||||||
|
<x-admin::form-group label="Category" name="category_id">
|
||||||
|
<x-admin::select
|
||||||
|
name="category_id"
|
||||||
|
:options="$categories"
|
||||||
|
:value="old('category_id', $post->category_id)"
|
||||||
|
placeholder="Select a category..."
|
||||||
|
/>
|
||||||
|
</x-admin::form-group>
|
||||||
|
|
||||||
|
{{-- Options --}}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<x-admin::checkbox
|
||||||
|
name="featured"
|
||||||
|
label="Featured Post"
|
||||||
|
:checked="old('featured', $post->featured)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-admin::toggle
|
||||||
|
name="comments_enabled"
|
||||||
|
label="Enable Comments"
|
||||||
|
:checked="old('comments_enabled', $post->comments_enabled)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Actions --}}
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<x-admin::button type="submit">
|
||||||
|
Save Post
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
<x-admin::button
|
||||||
|
variant="secondary"
|
||||||
|
href="{{ route('admin.posts.index') }}"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
<x-admin::button
|
||||||
|
variant="danger"
|
||||||
|
hidden-unless="posts.delete"
|
||||||
|
wire:click="delete"
|
||||||
|
wire:confirm="Delete this post permanently?"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</x-admin::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
Components use Tailwind CSS and can be customized:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::input
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
class="font-mono"
|
||||||
|
input-class="bg-gray-50"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Wrapper Classes
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::form-group
|
||||||
|
label="Title"
|
||||||
|
name="title"
|
||||||
|
wrapper-class="max-w-xl"
|
||||||
|
>
|
||||||
|
<x-admin::input name="title" />
|
||||||
|
</x-admin::form-group>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Always Use Form Groups
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - wrapped in form-group --}}
|
||||||
|
<x-admin::form-group label="Title" name="title" required>
|
||||||
|
<x-admin::input name="title" />
|
||||||
|
</x-admin::form-group>
|
||||||
|
|
||||||
|
{{-- ❌ Bad - no form-group --}}
|
||||||
|
<x-admin::input name="title" label="Title" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Old Values
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - preserves input on validation errors --}}
|
||||||
|
<x-admin::input
|
||||||
|
name="title"
|
||||||
|
:value="old('title', $post->title)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- ❌ Bad - loses input on validation errors --}}
|
||||||
|
<x-admin::input
|
||||||
|
name="title"
|
||||||
|
:value="$post->title"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Provide Helpful Placeholders
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - clear placeholder --}}
|
||||||
|
<x-admin::input
|
||||||
|
name="slug"
|
||||||
|
placeholder="post-slug-example"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- ❌ Bad - vague placeholder --}}
|
||||||
|
<x-admin::input
|
||||||
|
name="slug"
|
||||||
|
placeholder="Enter slug"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Authorization Props
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - respects permissions --}}
|
||||||
|
<x-admin::button
|
||||||
|
variant="danger"
|
||||||
|
hidden-unless="posts.delete"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</x-admin::button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Livewire Modals →](/packages/admin/modals)
|
||||||
|
- [Authorization →](/packages/admin/authorization)
|
||||||
|
- [HLCRF Layouts →](/packages/admin/hlcrf)
|
||||||
843
docs/hlcrf-deep-dive.md
Normal file
843
docs/hlcrf-deep-dive.md
Normal file
|
|
@ -0,0 +1,843 @@
|
||||||
|
# HLCRF Deep Dive
|
||||||
|
|
||||||
|
This guide provides an in-depth look at the HLCRF (Header-Left-Content-Right-Footer) layout system, covering all layout combinations, the ID system, responsive patterns, and complex real-world examples.
|
||||||
|
|
||||||
|
## Layout Combinations
|
||||||
|
|
||||||
|
HLCRF supports any combination of its five regions. The variant name describes which regions are present.
|
||||||
|
|
||||||
|
### All Possible Combinations
|
||||||
|
|
||||||
|
| Variant | Regions | Use Case |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| `C` | Content only | Simple content pages |
|
||||||
|
| `HC` | Header + Content | Landing pages |
|
||||||
|
| `CF` | Content + Footer | Article pages |
|
||||||
|
| `HCF` | Header + Content + Footer | Standard pages |
|
||||||
|
| `LC` | Left + Content | App with navigation |
|
||||||
|
| `CR` | Content + Right | Content with sidebar |
|
||||||
|
| `LCR` | Left + Content + Right | Three-column layout |
|
||||||
|
| `HLC` | Header + Left + Content | Admin dashboard |
|
||||||
|
| `HCR` | Header + Content + Right | Blog with widgets |
|
||||||
|
| `LCF` | Left + Content + Footer | App with footer |
|
||||||
|
| `CRF` | Content + Right + Footer | Blog layout |
|
||||||
|
| `HLCF` | Header + Left + Content + Footer | Standard admin |
|
||||||
|
| `HCRF` | Header + Content + Right + Footer | Blog layout |
|
||||||
|
| `HLCR` | Header + Left + Content + Right | Full admin |
|
||||||
|
| `LCRF` | Left + Content + Right + Footer | Complex app |
|
||||||
|
| `HLCRF` | All five regions | Complete layout |
|
||||||
|
|
||||||
|
### Content-Only (C)
|
||||||
|
|
||||||
|
Minimal layout for simple content:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Components\Layout;
|
||||||
|
|
||||||
|
$layout = Layout::make('C')
|
||||||
|
->c('<main>Simple content without chrome</main>');
|
||||||
|
|
||||||
|
echo $layout->render();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```html
|
||||||
|
<div class="hlcrf-layout" data-layout="root">
|
||||||
|
<div class="hlcrf-body flex flex-1">
|
||||||
|
<main class="hlcrf-content flex-1" data-slot="C">
|
||||||
|
<div data-block="C-0">
|
||||||
|
<main>Simple content without chrome</main>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Header + Content + Footer (HCF)
|
||||||
|
|
||||||
|
Standard page layout:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('HCF')
|
||||||
|
->h('<nav>Site Navigation</nav>')
|
||||||
|
->c('<article>Page Content</article>')
|
||||||
|
->f('<footer>Copyright 2026</footer>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Left + Content (LC)
|
||||||
|
|
||||||
|
Application with navigation sidebar:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('LC')
|
||||||
|
->l('<nav class="w-64">App Menu</nav>')
|
||||||
|
->c('<main>App Content</main>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Three-Column (LCR)
|
||||||
|
|
||||||
|
Full three-column layout:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('LCR')
|
||||||
|
->l('<nav>Navigation</nav>')
|
||||||
|
->c('<main>Content</main>')
|
||||||
|
->r('<aside>Widgets</aside>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Admin (HLCRF)
|
||||||
|
|
||||||
|
Complete admin panel:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('HLCRF')
|
||||||
|
->h('<header>Admin Header</header>')
|
||||||
|
->l('<nav>Sidebar</nav>')
|
||||||
|
->c('<main>Dashboard</main>')
|
||||||
|
->r('<aside>Quick Actions</aside>')
|
||||||
|
->f('<footer>Status Bar</footer>');
|
||||||
|
```
|
||||||
|
|
||||||
|
## The ID System
|
||||||
|
|
||||||
|
Every HLCRF element receives a unique, hierarchical ID that describes its position in the layout tree.
|
||||||
|
|
||||||
|
### ID Format
|
||||||
|
|
||||||
|
```
|
||||||
|
{Region}-{Index}[-{NestedRegion}-{NestedIndex}]...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- **Region Letter** - `H`, `L`, `C`, `R`, or `F`
|
||||||
|
- **Index** - Zero-based position within that slot (0, 1, 2, ...)
|
||||||
|
- **Nesting** - Dash-separated chain for nested layouts
|
||||||
|
|
||||||
|
### Region Letters
|
||||||
|
|
||||||
|
| Letter | Region | Semantic Role |
|
||||||
|
|--------|--------|---------------|
|
||||||
|
| `H` | Header | Top navigation, branding |
|
||||||
|
| `L` | Left | Primary sidebar, navigation |
|
||||||
|
| `C` | Content | Main content area |
|
||||||
|
| `R` | Right | Secondary sidebar, widgets |
|
||||||
|
| `F` | Footer | Bottom links, copyright |
|
||||||
|
|
||||||
|
### ID Examples
|
||||||
|
|
||||||
|
**Simple layout:**
|
||||||
|
```html
|
||||||
|
<div data-layout="root">
|
||||||
|
<header data-slot="H">
|
||||||
|
<div data-block="H-0">First header element</div>
|
||||||
|
<div data-block="H-1">Second header element</div>
|
||||||
|
</header>
|
||||||
|
<main data-slot="C">
|
||||||
|
<div data-block="C-0">First content element</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nested layout:**
|
||||||
|
```html
|
||||||
|
<div data-layout="root">
|
||||||
|
<main data-slot="C">
|
||||||
|
<div data-block="C-0">
|
||||||
|
<!-- Nested layout inside content -->
|
||||||
|
<div data-layout="C-0-">
|
||||||
|
<aside data-slot="C-0-L">
|
||||||
|
<div data-block="C-0-L-0">Nested left sidebar</div>
|
||||||
|
</aside>
|
||||||
|
<main data-slot="C-0-C">
|
||||||
|
<div data-block="C-0-C-0">Nested content</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ID Interpretation
|
||||||
|
|
||||||
|
| ID | Meaning |
|
||||||
|
|----|---------|
|
||||||
|
| `H-0` | First element in Header |
|
||||||
|
| `L-2` | Third element in Left sidebar |
|
||||||
|
| `C-0` | First element in Content |
|
||||||
|
| `C-L-0` | Content > Left > First element |
|
||||||
|
| `C-R-2` | Content > Right > Third element |
|
||||||
|
| `C-L-0-R-1` | Content > Left > First > Right > Second |
|
||||||
|
| `H-0-C-0-L-0` | Header > Content > Left (deeply nested) |
|
||||||
|
|
||||||
|
### Using IDs for CSS
|
||||||
|
|
||||||
|
The ID system enables precise CSS targeting:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Target first header element */
|
||||||
|
[data-block="H-0"] {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target all elements in left sidebar */
|
||||||
|
[data-slot="L"] > [data-block] {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target nested content areas */
|
||||||
|
[data-block*="-C-"] {
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target second element in any right sidebar */
|
||||||
|
[data-block$="-R-1"] {
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target deeply nested layouts */
|
||||||
|
[data-layout*="-"][data-layout*="-"] {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using IDs for Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
// PHPUnit/Pest
|
||||||
|
$this->assertSee('[data-block="H-0"]');
|
||||||
|
$this->assertSeeInOrder(['[data-slot="L"]', '[data-slot="C"]']);
|
||||||
|
|
||||||
|
// Playwright/Cypress
|
||||||
|
await page.locator('[data-block="C-0"]').click();
|
||||||
|
await expect(page.locator('[data-slot="R"]')).toBeVisible();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using IDs for JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Target specific elements
|
||||||
|
const header = document.querySelector('[data-block="H-0"]');
|
||||||
|
const sidebar = document.querySelector('[data-slot="L"]');
|
||||||
|
|
||||||
|
// Dynamic targeting
|
||||||
|
function getContentBlock(index) {
|
||||||
|
return document.querySelector(`[data-block="C-${index}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested targeting
|
||||||
|
const nestedLeft = document.querySelector('[data-block="C-L-0"]');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Design Patterns
|
||||||
|
|
||||||
|
### Mobile-First Stacking
|
||||||
|
|
||||||
|
On mobile, stack regions vertically:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout
|
||||||
|
:breakpoints="[
|
||||||
|
'mobile' => 'stack',
|
||||||
|
'tablet' => 'LC',
|
||||||
|
'desktop' => 'LCR',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<x-hlcrf::left>Navigation</x-hlcrf::left>
|
||||||
|
<x-hlcrf::content>Content</x-hlcrf::content>
|
||||||
|
<x-hlcrf::right>Widgets</x-hlcrf::right>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- **Mobile (< 768px):** Left -> Content -> Right (vertical)
|
||||||
|
- **Tablet (768px-1024px):** Left | Content (two columns)
|
||||||
|
- **Desktop (> 1024px):** Left | Content | Right (three columns)
|
||||||
|
|
||||||
|
### Collapsible Sidebars
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::left
|
||||||
|
collapsible="true"
|
||||||
|
collapsed-width="64px"
|
||||||
|
expanded-width="256px"
|
||||||
|
:collapsed="$sidebarCollapsed"
|
||||||
|
>
|
||||||
|
<div class="sidebar-content">
|
||||||
|
@if(!$sidebarCollapsed)
|
||||||
|
<span>Full navigation content</span>
|
||||||
|
@else
|
||||||
|
<span>Icons only</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::left>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hidden Regions on Mobile
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::right
|
||||||
|
class="hidden md:block"
|
||||||
|
width="300px"
|
||||||
|
>
|
||||||
|
{{-- Only visible on medium screens and up --}}
|
||||||
|
<x-widget-panel />
|
||||||
|
</x-hlcrf::right>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flexible Width Distribution
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::left width="250px" class="shrink-0">
|
||||||
|
Fixed-width sidebar
|
||||||
|
</x-hlcrf::left>
|
||||||
|
|
||||||
|
<x-hlcrf::content class="flex-1 min-w-0">
|
||||||
|
Flexible content
|
||||||
|
</x-hlcrf::content>
|
||||||
|
|
||||||
|
<x-hlcrf::right width="25%" class="shrink-0">
|
||||||
|
Percentage-width sidebar
|
||||||
|
</x-hlcrf::right>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Grid Inside Content
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::content>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<x-stat-card title="Users" :value="$userCount" />
|
||||||
|
<x-stat-card title="Posts" :value="$postCount" />
|
||||||
|
<x-stat-card title="Comments" :value="$commentCount" />
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::content>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Real-World Examples
|
||||||
|
|
||||||
|
### Admin Dashboard
|
||||||
|
|
||||||
|
A complete admin dashboard with nested layouts:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Components\Layout;
|
||||||
|
|
||||||
|
// Main admin layout
|
||||||
|
$admin = Layout::make('HLCF')
|
||||||
|
->h(
|
||||||
|
'<nav class="flex items-center justify-between px-4 py-2 bg-gray-900 text-white">
|
||||||
|
<div class="logo">Admin Panel</div>
|
||||||
|
<div class="user-menu">
|
||||||
|
<span>user@example.com</span>
|
||||||
|
</div>
|
||||||
|
</nav>'
|
||||||
|
)
|
||||||
|
->l(
|
||||||
|
'<nav class="w-64 bg-gray-800 text-gray-300 min-h-screen p-4">
|
||||||
|
<a href="/dashboard" class="block py-2">Dashboard</a>
|
||||||
|
<a href="/users" class="block py-2">Users</a>
|
||||||
|
<a href="/settings" class="block py-2">Settings</a>
|
||||||
|
</nav>'
|
||||||
|
)
|
||||||
|
->c(
|
||||||
|
// Nested layout inside content
|
||||||
|
Layout::make('HCR')
|
||||||
|
->h('<div class="flex items-center justify-between p-4 border-b">
|
||||||
|
<h1 class="text-xl font-semibold">Dashboard</h1>
|
||||||
|
<button class="btn-primary">New Item</button>
|
||||||
|
</div>')
|
||||||
|
->c('<div class="p-6">
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="bg-white p-4 rounded shadow">Stat 1</div>
|
||||||
|
<div class="bg-white p-4 rounded shadow">Stat 2</div>
|
||||||
|
<div class="bg-white p-4 rounded shadow">Stat 3</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white p-4 rounded shadow">
|
||||||
|
<h2 class="font-medium mb-4">Recent Activity</h2>
|
||||||
|
<table class="w-full">...</table>
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
->r('<aside class="w-80 p-4 bg-gray-50 border-l">
|
||||||
|
<h3 class="font-medium mb-4">Quick Actions</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button class="w-full btn-secondary">Export Data</button>
|
||||||
|
<button class="w-full btn-secondary">Generate Report</button>
|
||||||
|
</div>
|
||||||
|
</aside>')
|
||||||
|
)
|
||||||
|
->f(
|
||||||
|
'<footer class="px-4 py-2 bg-gray-100 text-gray-600 text-sm">
|
||||||
|
Version 1.0.0 | Last sync: 5 minutes ago
|
||||||
|
</footer>'
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $admin->render();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated IDs:**
|
||||||
|
- `H-0` - Admin header/navigation
|
||||||
|
- `L-0` - Sidebar navigation
|
||||||
|
- `C-0` - Nested layout container
|
||||||
|
- `C-0-H-0` - Content header (page title/actions)
|
||||||
|
- `C-0-C-0` - Content main area (stats/table)
|
||||||
|
- `C-0-R-0` - Content right sidebar (quick actions)
|
||||||
|
- `F-0` - Admin footer
|
||||||
|
|
||||||
|
### E-Commerce Product Page
|
||||||
|
|
||||||
|
Product page with nested sections:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$productPage = Layout::make('HCF')
|
||||||
|
->h('<header class="border-b">
|
||||||
|
<nav>Store Navigation</nav>
|
||||||
|
<div>Search | Cart | Account</div>
|
||||||
|
</header>')
|
||||||
|
->c(
|
||||||
|
Layout::make('LCR')
|
||||||
|
->l('<div class="w-1/2">
|
||||||
|
<div class="aspect-square bg-gray-100">
|
||||||
|
<img src="/product-main.jpg" alt="Product" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<img src="/thumb-1.jpg" class="w-16 h-16" />
|
||||||
|
<img src="/thumb-2.jpg" class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
->c(
|
||||||
|
// Empty - using left/right only
|
||||||
|
)
|
||||||
|
->r('<div class="w-1/2 p-6">
|
||||||
|
<h1 class="text-2xl font-bold">Product Name</h1>
|
||||||
|
<p class="text-xl text-green-600 mt-2">$99.99</p>
|
||||||
|
<p class="mt-4">Product description...</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<select>Size options</select>
|
||||||
|
<button class="w-full btn-primary">Add to Cart</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 border-t pt-4">
|
||||||
|
<h3>Shipping Info</h3>
|
||||||
|
<p>Free delivery over $50</p>
|
||||||
|
</div>
|
||||||
|
</div>'),
|
||||||
|
// Reviews section
|
||||||
|
Layout::make('CR')
|
||||||
|
->c('<div class="p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Customer Reviews</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-b pb-4">Review 1...</div>
|
||||||
|
<div class="border-b pb-4">Review 2...</div>
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
->r('<aside class="w-64 p-4 bg-gray-50">
|
||||||
|
<h3>You May Also Like</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>Related Product 1</div>
|
||||||
|
<div>Related Product 2</div>
|
||||||
|
</div>
|
||||||
|
</aside>')
|
||||||
|
)
|
||||||
|
->f('<footer class="bg-gray-900 text-white p-8">
|
||||||
|
<div class="grid grid-cols-4 gap-8">
|
||||||
|
<div>About Us</div>
|
||||||
|
<div>Customer Service</div>
|
||||||
|
<div>Policies</div>
|
||||||
|
<div>Newsletter</div>
|
||||||
|
</div>
|
||||||
|
</footer>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Panel Settings Page
|
||||||
|
|
||||||
|
Settings page with multiple nested panels:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$settings = Layout::make('HLC')
|
||||||
|
->h('<header class="border-b p-4">
|
||||||
|
<h1>Account Settings</h1>
|
||||||
|
</header>')
|
||||||
|
->l('<nav class="w-48 border-r">
|
||||||
|
<a href="#profile" class="block p-3 bg-blue-50">Profile</a>
|
||||||
|
<a href="#security" class="block p-3">Security</a>
|
||||||
|
<a href="#notifications" class="block p-3">Notifications</a>
|
||||||
|
<a href="#billing" class="block p-3">Billing</a>
|
||||||
|
</nav>')
|
||||||
|
->c(
|
||||||
|
// Profile section
|
||||||
|
Layout::make('HCF')
|
||||||
|
->h('<div class="p-4 border-b">
|
||||||
|
<h2 class="font-semibold">Profile Information</h2>
|
||||||
|
<p class="text-gray-600 text-sm">Update your account details</p>
|
||||||
|
</div>')
|
||||||
|
->c('<form class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" value="John Doe" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" value="john@example.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Bio</label>
|
||||||
|
<textarea rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>')
|
||||||
|
->f('<div class="p-4 border-t bg-gray-50 flex justify-end gap-2">
|
||||||
|
<button class="btn-secondary">Cancel</button>
|
||||||
|
<button class="btn-primary">Save Changes</button>
|
||||||
|
</div>')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation Site
|
||||||
|
|
||||||
|
Documentation layout with table of contents:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$docs = Layout::make('HLCRF')
|
||||||
|
->h('<header class="border-b">
|
||||||
|
<div class="flex items-center justify-between px-6 py-3">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img src="/logo.svg" class="h-8" />
|
||||||
|
<nav class="hidden md:flex gap-6">
|
||||||
|
<a href="/docs">Docs</a>
|
||||||
|
<a href="/api">API</a>
|
||||||
|
<a href="/examples">Examples</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<input type="search" placeholder="Search..." />
|
||||||
|
<a href="/github">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>')
|
||||||
|
->l('<nav class="w-64 p-4 border-r overflow-y-auto">
|
||||||
|
<h4 class="font-semibold text-gray-500 uppercase text-xs mb-2">Getting Started</h4>
|
||||||
|
<a href="/docs/intro" class="block py-1 text-blue-600">Introduction</a>
|
||||||
|
<a href="/docs/install" class="block py-1">Installation</a>
|
||||||
|
<a href="/docs/quick-start" class="block py-1">Quick Start</a>
|
||||||
|
|
||||||
|
<h4 class="font-semibold text-gray-500 uppercase text-xs mt-6 mb-2">Core Concepts</h4>
|
||||||
|
<a href="/docs/layouts" class="block py-1">Layouts</a>
|
||||||
|
<a href="/docs/components" class="block py-1">Components</a>
|
||||||
|
<a href="/docs/routing" class="block py-1">Routing</a>
|
||||||
|
</nav>')
|
||||||
|
->c('<article class="prose max-w-3xl mx-auto p-8">
|
||||||
|
<h1>Introduction</h1>
|
||||||
|
<p>Welcome to the documentation...</p>
|
||||||
|
|
||||||
|
<h2>Key Features</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Feature 1</li>
|
||||||
|
<li>Feature 2</li>
|
||||||
|
<li>Feature 3</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Next Steps</h2>
|
||||||
|
<p>Continue to the installation guide...</p>
|
||||||
|
</article>')
|
||||||
|
->r('<aside class="w-48 p-4 border-l">
|
||||||
|
<h4 class="font-semibold text-sm mb-2">On This Page</h4>
|
||||||
|
<nav class="text-sm space-y-1">
|
||||||
|
<a href="#intro" class="block text-gray-600">Introduction</a>
|
||||||
|
<a href="#features" class="block text-gray-600">Key Features</a>
|
||||||
|
<a href="#next" class="block text-gray-600">Next Steps</a>
|
||||||
|
</nav>
|
||||||
|
</aside>')
|
||||||
|
->f('<footer class="border-t p-4 flex justify-between text-sm text-gray-600">
|
||||||
|
<div>
|
||||||
|
<a href="/prev" class="text-blue-600">← Previous: Setup</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/next" class="text-blue-600">Next: Installation →</a>
|
||||||
|
</div>
|
||||||
|
</footer>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email Client Interface
|
||||||
|
|
||||||
|
Complex email client with multiple nested panels:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$email = Layout::make('HLCR')
|
||||||
|
->h('<header class="bg-white border-b px-4 py-2 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="btn-icon">Menu</button>
|
||||||
|
<img src="/logo.svg" class="h-6" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 max-w-2xl mx-4">
|
||||||
|
<input type="search" placeholder="Search mail" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="btn-icon">Settings</button>
|
||||||
|
<div class="avatar">JD</div>
|
||||||
|
</div>
|
||||||
|
</header>')
|
||||||
|
->l('<aside class="w-64 border-r flex flex-col">
|
||||||
|
<div class="p-3">
|
||||||
|
<button class="w-full btn-primary">Compose</button>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 overflow-y-auto">
|
||||||
|
<a href="#inbox" class="flex items-center gap-3 px-4 py-2 bg-blue-50">
|
||||||
|
<span class="icon">inbox</span>
|
||||||
|
<span class="flex-1">Inbox</span>
|
||||||
|
<span class="badge">12</span>
|
||||||
|
</a>
|
||||||
|
<a href="#starred" class="flex items-center gap-3 px-4 py-2">
|
||||||
|
<span class="icon">star</span>
|
||||||
|
<span>Starred</span>
|
||||||
|
</a>
|
||||||
|
<a href="#sent" class="flex items-center gap-3 px-4 py-2">
|
||||||
|
<span class="icon">send</span>
|
||||||
|
<span>Sent</span>
|
||||||
|
</a>
|
||||||
|
<a href="#drafts" class="flex items-center gap-3 px-4 py-2">
|
||||||
|
<span class="icon">draft</span>
|
||||||
|
<span>Drafts</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="p-4 border-t text-sm text-gray-600">
|
||||||
|
Storage: 2.4 GB / 15 GB
|
||||||
|
</div>
|
||||||
|
</aside>')
|
||||||
|
->c(
|
||||||
|
Layout::make('LC')
|
||||||
|
->l('<div class="w-80 border-r overflow-y-auto">
|
||||||
|
<div class="p-2 border-b">
|
||||||
|
<select class="w-full text-sm">
|
||||||
|
<option>All Mail</option>
|
||||||
|
<option>Unread</option>
|
||||||
|
<option>Starred</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y">
|
||||||
|
<div class="p-3 bg-blue-50 cursor-pointer">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium">John Smith</span>
|
||||||
|
<span class="text-xs text-gray-500">10:30 AM</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-medium text-sm">Meeting Tomorrow</div>
|
||||||
|
<div class="text-sm text-gray-600 truncate">Hi, just wanted to confirm...</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 cursor-pointer">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium">Jane Doe</span>
|
||||||
|
<span class="text-xs text-gray-500">Yesterday</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-medium text-sm">Project Update</div>
|
||||||
|
<div class="text-sm text-gray-600 truncate">Here is the latest update...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
->c('<div class="flex-1 flex flex-col">
|
||||||
|
<div class="p-4 border-b flex items-center gap-2">
|
||||||
|
<button class="btn-icon">Archive</button>
|
||||||
|
<button class="btn-icon">Delete</button>
|
||||||
|
<button class="btn-icon">Move</button>
|
||||||
|
<span class="border-l h-6 mx-2"></span>
|
||||||
|
<button class="btn-icon">Reply</button>
|
||||||
|
<button class="btn-icon">Forward</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-medium">Meeting Tomorrow</h2>
|
||||||
|
<div class="flex items-center gap-3 mt-2 text-sm text-gray-600">
|
||||||
|
<div class="avatar">JS</div>
|
||||||
|
<div>
|
||||||
|
<div>John Smith <john@example.com></div>
|
||||||
|
<div>to me</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto">Jan 15, 2026, 10:30 AM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>Hi,</p>
|
||||||
|
<p>Just wanted to confirm our meeting tomorrow at 2pm.</p>
|
||||||
|
<p>Best regards,<br>John</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
)
|
||||||
|
->r('<aside class="w-64 border-l p-4 hidden xl:block">
|
||||||
|
<h3 class="font-medium mb-4">Contact Info</h3>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<div>John Smith</div>
|
||||||
|
<div class="text-gray-600">john@example.com</div>
|
||||||
|
<div class="text-gray-600">+1 555 123 4567</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-medium mt-6 mb-4">Related Emails</h3>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<a href="#" class="block text-blue-600">Re: Project Timeline</a>
|
||||||
|
<a href="#" class="block text-blue-600">Meeting Notes</a>
|
||||||
|
</div>
|
||||||
|
</aside>');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Lazy Content Loading
|
||||||
|
|
||||||
|
For large layouts, defer non-critical content:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('LCR')
|
||||||
|
->l('<nav>Immediate navigation</nav>')
|
||||||
|
->c('<main wire:init="loadContent">
|
||||||
|
<div wire:loading>Loading...</div>
|
||||||
|
<div wire:loading.remove>@livewire("content-panel")</div>
|
||||||
|
</main>')
|
||||||
|
->r(fn () => view('widgets.sidebar')); // Closure defers evaluation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Region Rendering
|
||||||
|
|
||||||
|
Only render regions when needed:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('LCR');
|
||||||
|
|
||||||
|
$layout->l('<nav>Navigation</nav>');
|
||||||
|
$layout->c('<main>Content</main>');
|
||||||
|
|
||||||
|
// Conditionally add right sidebar
|
||||||
|
if ($user->hasFeature('widgets')) {
|
||||||
|
$layout->r('<aside>Widgets</aside>');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Efficient CSS Targeting
|
||||||
|
|
||||||
|
Use data attributes instead of deep selectors:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Efficient - uses data attribute */
|
||||||
|
[data-block="C-0"] { padding: 1rem; }
|
||||||
|
|
||||||
|
/* Less efficient - deep selector */
|
||||||
|
.hlcrf-layout > .hlcrf-body > .hlcrf-content > div:first-child { padding: 1rem; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing HLCRF Layouts
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Components\Layout;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class LayoutTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_generates_correct_ids(): void
|
||||||
|
{
|
||||||
|
$layout = Layout::make('LC')
|
||||||
|
->l('Left')
|
||||||
|
->c('Content');
|
||||||
|
|
||||||
|
$html = $layout->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('data-slot="L"', $html);
|
||||||
|
$this->assertStringContainsString('data-slot="C"', $html);
|
||||||
|
$this->assertStringContainsString('data-block="L-0"', $html);
|
||||||
|
$this->assertStringContainsString('data-block="C-0"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nested_layout_ids(): void
|
||||||
|
{
|
||||||
|
$nested = Layout::make('LR')
|
||||||
|
->l('Nested Left')
|
||||||
|
->r('Nested Right');
|
||||||
|
|
||||||
|
$outer = Layout::make('C')
|
||||||
|
->c($nested);
|
||||||
|
|
||||||
|
$html = $outer->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('data-block="C-0-L-0"', $html);
|
||||||
|
$this->assertStringContainsString('data-block="C-0-R-0"', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browser Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Pest with Playwright
|
||||||
|
it('renders admin layout correctly', function () {
|
||||||
|
$this->browse(function ($browser) {
|
||||||
|
$browser->visit('/admin')
|
||||||
|
->assertPresent('[data-layout="root"]')
|
||||||
|
->assertPresent('[data-slot="H"]')
|
||||||
|
->assertPresent('[data-slot="L"]')
|
||||||
|
->assertPresent('[data-slot="C"]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Semantic Region Names
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good - semantic use
|
||||||
|
->h('<nav>Global navigation</nav>')
|
||||||
|
->l('<nav>Page navigation</nav>')
|
||||||
|
->c('<main>Page content</main>')
|
||||||
|
->r('<aside>Related content</aside>')
|
||||||
|
->f('<footer>Site footer</footer>')
|
||||||
|
|
||||||
|
// Bad - misuse of regions
|
||||||
|
->h('<aside>Sidebar content</aside>') // Header for sidebar?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Leverage the ID System
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Target specific elements precisely */
|
||||||
|
[data-block="H-0"] { /* Header first element */ }
|
||||||
|
[data-block="C-L-0"] { /* Content > Left > First */ }
|
||||||
|
|
||||||
|
/* Don't fight the system with complex selectors */
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Keep Nesting Shallow
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good - 2-3 levels max
|
||||||
|
Layout::make('HCF')
|
||||||
|
->c(Layout::make('LCR')->...);
|
||||||
|
|
||||||
|
// Avoid - too deep
|
||||||
|
Layout::make('C')
|
||||||
|
->c(Layout::make('C')
|
||||||
|
->c(Layout::make('C')
|
||||||
|
->c(Layout::make('C')...))));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Consistent Widths
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good - consistent sidebar widths across app
|
||||||
|
->l('<nav class="w-64">') // Always 256px
|
||||||
|
->r('<aside class="w-80">') // Always 320px
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Handle Empty Regions Gracefully
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Regions without content don't render
|
||||||
|
$layout = Layout::make('LCR')
|
||||||
|
->l('<nav>Nav</nav>')
|
||||||
|
->c('<main>Content</main>');
|
||||||
|
// No ->r() call - right sidebar won't render
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [HLCRF Pattern Overview](/patterns-guide/hlcrf)
|
||||||
|
- [Form Components](/packages/admin/forms)
|
||||||
|
- [Livewire Modals](/packages/admin/modals)
|
||||||
|
- [Creating Admin Panels](/packages/admin/creating-admin-panels)
|
||||||
327
docs/index.md
Normal file
327
docs/index.md
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
# Admin Package
|
||||||
|
|
||||||
|
The Admin package provides a complete admin panel with Livewire modals, HLCRF layouts, form components, global search, and an extensible menu system.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require host-uk/core-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Events\AdminPanelBooting;
|
||||||
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
|
use Core\Front\Admin\Support\MenuItemBuilder;
|
||||||
|
|
||||||
|
class Boot
|
||||||
|
{
|
||||||
|
public static array $listens = [
|
||||||
|
AdminPanelBooting::class => 'onAdminPanel',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
// Register admin menu
|
||||||
|
$event->menu(new BlogMenuProvider());
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### User Interface
|
||||||
|
|
||||||
|
- **[HLCRF Layouts](/packages/admin/hlcrf)** - Composable layout system for admin interfaces
|
||||||
|
- **[Livewire Modals](/packages/admin/modals)** - Full-page modal system for forms and details
|
||||||
|
- **[Form Components](/packages/admin/forms)** - Pre-built form inputs with validation
|
||||||
|
- **[Admin Menus](/packages/admin/menus)** - Extensible navigation menu system
|
||||||
|
|
||||||
|
### Search & Discovery
|
||||||
|
|
||||||
|
- **[Global Search](/packages/admin/search)** - Unified search across all modules
|
||||||
|
- **[Search Providers](/packages/admin/search#providers)** - Register searchable resources
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- **[Data Tables](/packages/admin/tables)** - Sortable, filterable data tables
|
||||||
|
- **[Cards & Grids](/packages/admin/components#cards)** - Stat cards and grid layouts
|
||||||
|
- **[Buttons & Actions](/packages/admin/components#buttons)** - Action buttons with authorization
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **[Honeypot Protection](/packages/admin/security)** - Bot detection and logging
|
||||||
|
- **[Activity Feeds](/packages/admin/activity)** - Display recent activity logs
|
||||||
|
- **[Form Validation](/packages/admin/forms#validation)** - Client and server-side validation
|
||||||
|
|
||||||
|
## Components Overview
|
||||||
|
|
||||||
|
### Form Components
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::input name="title" label="Title" required />
|
||||||
|
<x-admin::textarea name="content" label="Content" rows="10" />
|
||||||
|
<x-admin::select name="status" label="Status" :options="$statuses" />
|
||||||
|
<x-admin::checkbox name="published" label="Published" />
|
||||||
|
<x-admin::toggle name="featured" label="Featured" />
|
||||||
|
<x-admin::button type="submit">Save</x-admin::button>
|
||||||
|
```
|
||||||
|
|
||||||
|
[Learn more about Forms →](/packages/admin/forms)
|
||||||
|
|
||||||
|
### Layout Components
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
</x-hlcrf::header>
|
||||||
|
|
||||||
|
<x-hlcrf::content>
|
||||||
|
<x-admin::card-grid>
|
||||||
|
<x-admin::stat-card title="Posts" :value="$postCount" />
|
||||||
|
<x-admin::stat-card title="Users" :value="$userCount" />
|
||||||
|
</x-admin::card-grid>
|
||||||
|
</x-hlcrf::content>
|
||||||
|
|
||||||
|
<x-hlcrf::right>
|
||||||
|
<x-admin::activity-feed :limit="10" />
|
||||||
|
</x-hlcrf::right>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
[Learn more about HLCRF Layouts →](/packages/admin/hlcrf)
|
||||||
|
|
||||||
|
## Admin Routes
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Routes/admin.php
|
||||||
|
use Mod\Blog\View\Modal\Admin\PostEditor;
|
||||||
|
use Mod\Blog\View\Modal\Admin\PostsList;
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'admin'])->prefix('admin')->group(function () {
|
||||||
|
// Livewire modal routes
|
||||||
|
Route::get('/posts', PostsList::class)->name('admin.blog.posts');
|
||||||
|
Route::get('/posts/create', PostEditor::class)->name('admin.blog.posts.create');
|
||||||
|
Route::get('/posts/{post}/edit', PostEditor::class)->name('admin.blog.posts.edit');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Livewire Modals
|
||||||
|
|
||||||
|
Create full-page modals for admin interfaces:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class PostEditor extends Component
|
||||||
|
{
|
||||||
|
public ?Post $post = null;
|
||||||
|
public string $title = '';
|
||||||
|
public string $content = '';
|
||||||
|
|
||||||
|
protected array $rules = [
|
||||||
|
'title' => 'required|max:255',
|
||||||
|
'content' => 'required',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(?Post $post = null): void
|
||||||
|
{
|
||||||
|
$this->post = $post;
|
||||||
|
$this->title = $post?->title ?? '';
|
||||||
|
$this->content = $post?->content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate();
|
||||||
|
|
||||||
|
if ($this->post) {
|
||||||
|
$this->post->update($validated);
|
||||||
|
} else {
|
||||||
|
Post::create($validated);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dispatch('post-saved');
|
||||||
|
$this->redirect(route('admin.blog.posts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('blog::admin.post-editor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[Learn more about Livewire Modals →](/packages/admin/modals)
|
||||||
|
|
||||||
|
## Global Search
|
||||||
|
|
||||||
|
Register searchable resources:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Search;
|
||||||
|
|
||||||
|
use Core\Admin\Search\Contracts\SearchProvider;
|
||||||
|
use Core\Admin\Search\SearchResult;
|
||||||
|
|
||||||
|
class PostSearchProvider implements SearchProvider
|
||||||
|
{
|
||||||
|
public function search(string $query): array
|
||||||
|
{
|
||||||
|
return Post::where('title', 'like', "%{$query}%")
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(fn (Post $post) => new SearchResult(
|
||||||
|
title: $post->title,
|
||||||
|
description: $post->excerpt,
|
||||||
|
url: route('admin.blog.posts.edit', $post),
|
||||||
|
icon: 'document-text',
|
||||||
|
category: 'Blog Posts'
|
||||||
|
))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategory(): string
|
||||||
|
{
|
||||||
|
return 'Blog';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Register in your Boot.php:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
$event->search(new PostSearchProvider());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[Learn more about Search →](/packages/admin/search)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/admin.php
|
||||||
|
return [
|
||||||
|
'middleware' => ['web', 'auth', 'admin'],
|
||||||
|
'prefix' => 'admin',
|
||||||
|
|
||||||
|
'menu' => [
|
||||||
|
'auto_discover' => true,
|
||||||
|
'cache_enabled' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'search' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'min_length' => 2,
|
||||||
|
'limit' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
|
'honeypot' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'field_name' => env('HONEYPOT_FIELD', 'website'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Middleware
|
||||||
|
|
||||||
|
The admin panel uses these middleware by default:
|
||||||
|
|
||||||
|
- `web` - Web routes, sessions, CSRF
|
||||||
|
- `auth` - Require authentication
|
||||||
|
- `admin` - Check user is admin (gates/policies)
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Livewire Modals for Forms
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - Livewire modal
|
||||||
|
Route::get('/posts/create', PostEditor::class);
|
||||||
|
|
||||||
|
// ❌ Bad - Traditional controller
|
||||||
|
Route::get('/posts/create', [PostController::class, 'create']);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Form Components
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - consistent styling --}}
|
||||||
|
<x-admin::input name="title" label="Title" required />
|
||||||
|
|
||||||
|
{{-- ❌ Bad - custom HTML --}}
|
||||||
|
<input type="text" name="title" class="form-input">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Register Search Providers
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - searchable resources
|
||||||
|
$event->search(new PostSearchProvider());
|
||||||
|
$event->search(new CategorySearchProvider());
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use HLCRF for Layouts
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - composable layout --}}
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>Header</x-hlcrf::header>
|
||||||
|
<x-hlcrf::content>Content</x-hlcrf::content>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Admin;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
|
||||||
|
class PostEditorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_admin_can_create_post(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->livewire(PostEditor::class)
|
||||||
|
->set('title', 'Test Post')
|
||||||
|
->set('content', 'Test content')
|
||||||
|
->call('save')
|
||||||
|
->assertRedirect(route('admin.blog.posts'));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('posts', [
|
||||||
|
'title' => 'Test Post',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [HLCRF Layouts →](/packages/admin/hlcrf)
|
||||||
|
- [Livewire Modals →](/packages/admin/modals)
|
||||||
|
- [Form Components →](/packages/admin/forms)
|
||||||
|
- [Admin Menus →](/packages/admin/menus)
|
||||||
|
- [Global Search →](/packages/admin/search)
|
||||||
234
docs/menus.md
Normal file
234
docs/menus.md
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
# Admin Menus
|
||||||
|
|
||||||
|
The Admin package provides an extensible menu system with automatic discovery, authorization, and icon support.
|
||||||
|
|
||||||
|
## Creating Menu Providers
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
|
use Core\Front\Admin\Support\MenuItemBuilder;
|
||||||
|
|
||||||
|
class BlogMenuProvider implements AdminMenuProvider
|
||||||
|
{
|
||||||
|
public function register(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
MenuItemBuilder::make('Blog')
|
||||||
|
->icon('newspaper')
|
||||||
|
->priority(30)
|
||||||
|
->children([
|
||||||
|
MenuItemBuilder::make('Posts')
|
||||||
|
->route('admin.blog.posts.index')
|
||||||
|
->icon('document-text')
|
||||||
|
->badge(fn () => Post::draft()->count()),
|
||||||
|
|
||||||
|
MenuItemBuilder::make('Categories')
|
||||||
|
->route('admin.blog.categories.index')
|
||||||
|
->icon('folder'),
|
||||||
|
|
||||||
|
MenuItemBuilder::make('Tags')
|
||||||
|
->route('admin.blog.tags.index')
|
||||||
|
->icon('tag'),
|
||||||
|
])
|
||||||
|
->build(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registering Menus
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In your Boot.php
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
$event->menu(new BlogMenuProvider());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Menu Item Properties
|
||||||
|
|
||||||
|
### Basic Item
|
||||||
|
|
||||||
|
```php
|
||||||
|
MenuItemBuilder::make('Dashboard')
|
||||||
|
->route('admin.dashboard')
|
||||||
|
->icon('home')
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### With URL
|
||||||
|
|
||||||
|
```php
|
||||||
|
MenuItemBuilder::make('External Link')
|
||||||
|
->url('https://example.com')
|
||||||
|
->icon('external-link')
|
||||||
|
->external() // Opens in new tab
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Children
|
||||||
|
|
||||||
|
```php
|
||||||
|
MenuItemBuilder::make('Content')
|
||||||
|
->icon('document')
|
||||||
|
->children([
|
||||||
|
MenuItemBuilder::make('Posts')->route('admin.posts'),
|
||||||
|
MenuItemBuilder::make('Pages')->route('admin.pages'),
|
||||||
|
])
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Badge
|
||||||
|
|
||||||
|
```php
|
||||||
|
MenuItemBuilder::make('Comments')
|
||||||
|
->route('admin.comments')
|
||||||
|
->badge(fn () => Comment::pending()->count())
|
||||||
|
->badgeColor('red')
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Authorization
|
||||||
|
|
||||||
|
```php
|
||||||
|
MenuItemBuilder::make('Settings')
|
||||||
|
->route('admin.settings')
|
||||||
|
->can('admin.settings.view')
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Priority
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Higher priority = appears first
|
||||||
|
MenuItemBuilder::make('Dashboard')
|
||||||
|
->priority(100)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
MenuItemBuilder::make('Settings')
|
||||||
|
->priority(10)
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Examples
|
||||||
|
|
||||||
|
### Dynamic Menu Based on Permissions
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function register(): array
|
||||||
|
{
|
||||||
|
$menu = MenuItemBuilder::make('Blog')->icon('newspaper');
|
||||||
|
|
||||||
|
if (Gate::allows('posts.view')) {
|
||||||
|
$menu->child(MenuItemBuilder::make('Posts')->route('admin.blog.posts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Gate::allows('categories.view')) {
|
||||||
|
$menu->child(MenuItemBuilder::make('Categories')->route('admin.blog.categories'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$menu->build()];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu with Active State
|
||||||
|
|
||||||
|
```php
|
||||||
|
MenuItemBuilder::make('Posts')
|
||||||
|
->route('admin.blog.posts')
|
||||||
|
->active(fn () => request()->routeIs('admin.blog.posts.*'))
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu with Count Badge
|
||||||
|
|
||||||
|
```php
|
||||||
|
MenuItemBuilder::make('Pending Reviews')
|
||||||
|
->route('admin.reviews.pending')
|
||||||
|
->badge(fn () => Review::pending()->count())
|
||||||
|
->badgeColor('yellow')
|
||||||
|
->badgeTooltip('Reviews awaiting moderation')
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Menu Groups
|
||||||
|
|
||||||
|
Organize related items:
|
||||||
|
|
||||||
|
```php
|
||||||
|
MenuItemBuilder::makeGroup('Content Management')
|
||||||
|
->priority(50)
|
||||||
|
->children([
|
||||||
|
MenuItemBuilder::make('Posts')->route('admin.posts'),
|
||||||
|
MenuItemBuilder::make('Pages')->route('admin.pages'),
|
||||||
|
MenuItemBuilder::make('Media')->route('admin.media'),
|
||||||
|
])
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icon Support
|
||||||
|
|
||||||
|
Menus support Heroicons:
|
||||||
|
|
||||||
|
```php
|
||||||
|
->icon('document-text') // Document icon
|
||||||
|
->icon('users') // Users icon
|
||||||
|
->icon('cog') // Settings icon
|
||||||
|
->icon('chart-bar') // Analytics icon
|
||||||
|
```
|
||||||
|
|
||||||
|
[Browse Heroicons →](https://heroicons.com)
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Meaningful Icons
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - clear icon
|
||||||
|
MenuItemBuilder::make('Posts')->icon('document-text')
|
||||||
|
|
||||||
|
// ❌ Bad - generic icon
|
||||||
|
MenuItemBuilder::make('Posts')->icon('square')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Priorities
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - logical ordering
|
||||||
|
MenuItemBuilder::make('Dashboard')->priority(100)
|
||||||
|
MenuItemBuilder::make('Posts')->priority(90)
|
||||||
|
MenuItemBuilder::make('Settings')->priority(10)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Authorization
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - respects permissions
|
||||||
|
MenuItemBuilder::make('Settings')
|
||||||
|
->can('admin.settings.view')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Keep Hierarchy Shallow
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - 2 levels max
|
||||||
|
Blog
|
||||||
|
├─ Posts
|
||||||
|
└─ Categories
|
||||||
|
|
||||||
|
// ❌ Bad - too deep
|
||||||
|
Content
|
||||||
|
└─ Blog
|
||||||
|
└─ Posts
|
||||||
|
└─ Published
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Authorization →](/packages/admin/authorization)
|
||||||
|
- [Livewire Modals →](/packages/admin/modals)
|
||||||
577
docs/modals.md
Normal file
577
docs/modals.md
Normal file
|
|
@ -0,0 +1,577 @@
|
||||||
|
# Livewire Modals
|
||||||
|
|
||||||
|
The Admin package uses Livewire components as full-page modals, providing a seamless admin interface without traditional page reloads.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Livewire modals in Core PHP:
|
||||||
|
- Render as full-page routes
|
||||||
|
- Support direct URL access
|
||||||
|
- Maintain browser history
|
||||||
|
- Work with back/forward buttons
|
||||||
|
- No JavaScript modal libraries needed
|
||||||
|
|
||||||
|
## Creating a Modal
|
||||||
|
|
||||||
|
### Basic Modal
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
class PostEditor extends Component
|
||||||
|
{
|
||||||
|
public ?Post $post = null;
|
||||||
|
public string $title = '';
|
||||||
|
public string $content = '';
|
||||||
|
public string $status = 'draft';
|
||||||
|
|
||||||
|
protected array $rules = [
|
||||||
|
'title' => 'required|max:255',
|
||||||
|
'content' => 'required',
|
||||||
|
'status' => 'required|in:draft,published',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(?Post $post = null): void
|
||||||
|
{
|
||||||
|
$this->post = $post;
|
||||||
|
|
||||||
|
if ($post) {
|
||||||
|
$this->title = $post->title;
|
||||||
|
$this->content = $post->content;
|
||||||
|
$this->status = $post->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate();
|
||||||
|
|
||||||
|
if ($this->post) {
|
||||||
|
$this->post->update($validated);
|
||||||
|
$message = 'Post updated successfully';
|
||||||
|
} else {
|
||||||
|
Post::create($validated);
|
||||||
|
$message = 'Post created successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->flash('success', $message);
|
||||||
|
$this->redirect(route('admin.blog.posts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('blog::admin.post-editor')
|
||||||
|
->layout('admin::layouts.modal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal View
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- resources/views/admin/post-editor.blade.php --}}
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1>{{ $post ? 'Edit Post' : 'Create Post' }}</h1>
|
||||||
|
|
||||||
|
<button wire:click="$redirect('{{ route('admin.blog.posts') }}')" class="btn-ghost">
|
||||||
|
<x-icon name="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::header>
|
||||||
|
|
||||||
|
<x-hlcrf::content>
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<x-admin::form-group label="Title" name="title" required>
|
||||||
|
<x-admin::input
|
||||||
|
name="title"
|
||||||
|
wire:model="title"
|
||||||
|
placeholder="Enter post title"
|
||||||
|
/>
|
||||||
|
</x-admin::form-group>
|
||||||
|
|
||||||
|
<x-admin::form-group label="Content" name="content" required>
|
||||||
|
<x-admin::textarea
|
||||||
|
name="content"
|
||||||
|
wire:model.defer="content"
|
||||||
|
rows="15"
|
||||||
|
/>
|
||||||
|
</x-admin::form-group>
|
||||||
|
|
||||||
|
<x-admin::form-group label="Status" name="status" required>
|
||||||
|
<x-admin::select
|
||||||
|
name="status"
|
||||||
|
:options="['draft' => 'Draft', 'published' => 'Published']"
|
||||||
|
wire:model="status"
|
||||||
|
/>
|
||||||
|
</x-admin::form-group>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<x-admin::button type="submit" :loading="$isSaving">
|
||||||
|
{{ $post ? 'Update' : 'Create' }} Post
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
<x-admin::button
|
||||||
|
variant="secondary"
|
||||||
|
wire:click="$redirect('{{ route('admin.blog.posts') }}')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</x-admin::button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</x-hlcrf::content>
|
||||||
|
|
||||||
|
<x-hlcrf::right>
|
||||||
|
<x-admin::help-panel>
|
||||||
|
<h3>Publishing Tips</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Write a clear, descriptive title</li>
|
||||||
|
<li>Use proper formatting in content</li>
|
||||||
|
<li>Save as draft to preview first</li>
|
||||||
|
</ul>
|
||||||
|
</x-admin::help-panel>
|
||||||
|
</x-hlcrf::right>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registering Modal Routes
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Routes/admin.php
|
||||||
|
use Mod\Blog\View\Modal\Admin\PostEditor;
|
||||||
|
use Mod\Blog\View\Modal\Admin\PostsList;
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'admin'])->prefix('admin/blog')->group(function () {
|
||||||
|
Route::get('/posts', PostsList::class)->name('admin.blog.posts');
|
||||||
|
Route::get('/posts/create', PostEditor::class)->name('admin.blog.posts.create');
|
||||||
|
Route::get('/posts/{post}/edit', PostEditor::class)->name('admin.blog.posts.edit');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Opening Modals
|
||||||
|
|
||||||
|
### Via Link
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<a href="{{ route('admin.blog.posts.create') }}" class="btn-primary">
|
||||||
|
New Post
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Via Livewire Navigate
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<button wire:navigate href="{{ route('admin.blog.posts.create') }}" class="btn-primary">
|
||||||
|
New Post
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Via JavaScript
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<button @click="window.location.href = '{{ route('admin.blog.posts.create') }}'">
|
||||||
|
New Post
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modal Layouts
|
||||||
|
|
||||||
|
### With HLCRF
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>
|
||||||
|
Modal Header
|
||||||
|
</x-hlcrf::header>
|
||||||
|
|
||||||
|
<x-hlcrf::content>
|
||||||
|
Modal Content
|
||||||
|
</x-hlcrf::content>
|
||||||
|
|
||||||
|
<x-hlcrf::footer>
|
||||||
|
Modal Footer
|
||||||
|
</x-hlcrf::footer>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full-Width Modal
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout variant="full-width">
|
||||||
|
<x-hlcrf::content>
|
||||||
|
Full-width content
|
||||||
|
</x-hlcrf::content>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Sidebar
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout variant="two-column">
|
||||||
|
<x-hlcrf::content>
|
||||||
|
Main content
|
||||||
|
</x-hlcrf::content>
|
||||||
|
|
||||||
|
<x-hlcrf::right width="300px">
|
||||||
|
Sidebar
|
||||||
|
</x-hlcrf::right>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### Modal with Confirmation
|
||||||
|
|
||||||
|
```php
|
||||||
|
public bool $showDeleteConfirmation = false;
|
||||||
|
|
||||||
|
public function confirmDelete(): void
|
||||||
|
{
|
||||||
|
$this->showDeleteConfirmation = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
$this->post->delete();
|
||||||
|
|
||||||
|
session()->flash('success', 'Post deleted');
|
||||||
|
$this->redirect(route('admin.blog.posts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelDelete(): void
|
||||||
|
{
|
||||||
|
$this->showDeleteConfirmation = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@if($showDeleteConfirmation)
|
||||||
|
<div class="fixed inset-0 bg-black/50 flex items-center justify-center">
|
||||||
|
<div class="bg-white p-6 rounded-lg max-w-md">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Delete Post?</h3>
|
||||||
|
<p class="mb-6">This action cannot be undone.</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<x-admin::button variant="danger" wire:click="delete">
|
||||||
|
Delete
|
||||||
|
</x-admin::button>
|
||||||
|
<x-admin::button variant="secondary" wire:click="cancelDelete">
|
||||||
|
Cancel
|
||||||
|
</x-admin::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal with Steps
|
||||||
|
|
||||||
|
```php
|
||||||
|
public int $step = 1;
|
||||||
|
|
||||||
|
public function nextStep(): void
|
||||||
|
{
|
||||||
|
$this->validateOnly('step' . $this->step);
|
||||||
|
$this->step++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previousStep(): void
|
||||||
|
{
|
||||||
|
$this->step--;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div>
|
||||||
|
@if($step === 1)
|
||||||
|
{{-- Step 1: Basic Info --}}
|
||||||
|
<x-admin::input name="title" wire:model="title" label="Title" />
|
||||||
|
<x-admin::button wire:click="nextStep">Next</x-admin::button>
|
||||||
|
@elseif($step === 2)
|
||||||
|
{{-- Step 2: Content --}}
|
||||||
|
<x-admin::textarea name="content" wire:model="content" label="Content" />
|
||||||
|
<x-admin::button wire:click="previousStep">Back</x-admin::button>
|
||||||
|
<x-admin::button wire:click="nextStep">Next</x-admin::button>
|
||||||
|
@else
|
||||||
|
{{-- Step 3: Review --}}
|
||||||
|
<div>Review and save...</div>
|
||||||
|
<x-admin::button wire:click="previousStep">Back</x-admin::button>
|
||||||
|
<x-admin::button wire:click="save">Save</x-admin::button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal with Live Search
|
||||||
|
|
||||||
|
```php
|
||||||
|
public string $search = '';
|
||||||
|
public array $results = [];
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->results = Post::where('title', 'like', "%{$this->search}%")
|
||||||
|
->limit(10)
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::input
|
||||||
|
name="search"
|
||||||
|
wire:model.live.debounce.300ms="search"
|
||||||
|
placeholder="Search posts..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
@foreach($results as $result)
|
||||||
|
<div class="p-3 hover:bg-gray-50 cursor-pointer" wire:click="selectPost({{ $result['id'] }})">
|
||||||
|
{{ $result['title'] }}
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Uploads
|
||||||
|
|
||||||
|
### Single File
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
|
class PostEditor extends Component
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
public $image;
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'image' => 'required|image|max:2048',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$path = $this->image->store('posts', 'public');
|
||||||
|
|
||||||
|
Post::create([
|
||||||
|
'image_path' => $path,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::form-group label="Featured Image" name="image">
|
||||||
|
<input type="file" wire:model="image" accept="image/*">
|
||||||
|
|
||||||
|
@if($image)
|
||||||
|
<img src="{{ $image->temporaryUrl() }}" class="mt-2 max-w-xs">
|
||||||
|
@endif
|
||||||
|
</x-admin::form-group>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Files
|
||||||
|
|
||||||
|
```php
|
||||||
|
public array $images = [];
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'images.*' => 'image|max:2048',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($this->images as $image) {
|
||||||
|
$path = $image->store('posts', 'public');
|
||||||
|
// Save path...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Real-Time Validation
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected array $rules = [
|
||||||
|
'title' => 'required|max:255',
|
||||||
|
'slug' => 'required|unique:posts,slug',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function updated($propertyName): void
|
||||||
|
{
|
||||||
|
$this->validateOnly($propertyName);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-admin::input
|
||||||
|
name="slug"
|
||||||
|
wire:model.live="slug"
|
||||||
|
label="Slug"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@error('slug')
|
||||||
|
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading States
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Show loading on specific action --}}
|
||||||
|
<x-admin::button wire:click="save" wire:loading.attr="disabled">
|
||||||
|
<span wire:loading.remove wire:target="save">Save</span>
|
||||||
|
<span wire:loading wire:target="save">Saving...</span>
|
||||||
|
</x-admin::button>
|
||||||
|
|
||||||
|
{{-- Disable form during loading --}}
|
||||||
|
<form wire:submit="save">
|
||||||
|
<div wire:loading.class="opacity-50 pointer-events-none">
|
||||||
|
{{-- Form fields --}}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{-- Spinner --}}
|
||||||
|
<div wire:loading wire:target="save" class="spinner"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### Dispatch Events
|
||||||
|
|
||||||
|
```php
|
||||||
|
// From modal
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
// Save logic...
|
||||||
|
|
||||||
|
$this->dispatch('post-saved', postId: $post->id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listen to Events
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In another component
|
||||||
|
protected $listeners = ['post-saved' => 'refreshPosts'];
|
||||||
|
|
||||||
|
public function refreshPosts(int $postId): void
|
||||||
|
{
|
||||||
|
$this->posts = Post::all();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- In Blade --}}
|
||||||
|
<div
|
||||||
|
x-data
|
||||||
|
@post-saved.window="alert('Post saved!')"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Route Model Binding
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - automatic model resolution
|
||||||
|
Route::get('/posts/{post}/edit', PostEditor::class);
|
||||||
|
|
||||||
|
public function mount(?Post $post = null): void
|
||||||
|
{
|
||||||
|
$this->post = $post;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Flash Messages
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - inform user of success
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
// Save logic...
|
||||||
|
|
||||||
|
session()->flash('success', 'Post saved');
|
||||||
|
$this->redirect(route('admin.blog.posts'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Validate Early
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - real-time validation
|
||||||
|
public function updated($propertyName): void
|
||||||
|
{
|
||||||
|
$this->validateOnly($propertyName);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Loading States
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- ✅ Good - show loading feedback --}}
|
||||||
|
<x-admin::button :loading="$isSaving">
|
||||||
|
Save
|
||||||
|
</x-admin::button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Admin;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mod\Blog\View\Modal\Admin\PostEditor;
|
||||||
|
|
||||||
|
class PostEditorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_creates_post(): void
|
||||||
|
{
|
||||||
|
Livewire::test(PostEditor::class)
|
||||||
|
->set('title', 'Test Post')
|
||||||
|
->set('content', 'Test content')
|
||||||
|
->set('status', 'published')
|
||||||
|
->call('save')
|
||||||
|
->assertRedirect(route('admin.blog.posts'));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('posts', [
|
||||||
|
'title' => 'Test Post',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_validates_required_fields(): void
|
||||||
|
{
|
||||||
|
Livewire::test(PostEditor::class)
|
||||||
|
->set('title', '')
|
||||||
|
->call('save')
|
||||||
|
->assertHasErrors(['title' => 'required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_updates_existing_post(): void
|
||||||
|
{
|
||||||
|
$post = Post::factory()->create();
|
||||||
|
|
||||||
|
Livewire::test(PostEditor::class, ['post' => $post])
|
||||||
|
->set('title', 'Updated Title')
|
||||||
|
->call('save')
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertEquals('Updated Title', $post->fresh()->title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Form Components →](/packages/admin/forms)
|
||||||
|
- [HLCRF Layouts →](/packages/admin/hlcrf)
|
||||||
|
- [Livewire Documentation →](https://livewire.laravel.com)
|
||||||
434
docs/search.md
Normal file
434
docs/search.md
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
# Global Search
|
||||||
|
|
||||||
|
The Admin package provides a unified global search system that searches across all registered modules and resources.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Global search features:
|
||||||
|
- Search across multiple modules
|
||||||
|
- Keyboard shortcut (Cmd/Ctrl + K)
|
||||||
|
- Real-time results
|
||||||
|
- Category grouping
|
||||||
|
- Icon support
|
||||||
|
- Direct navigation
|
||||||
|
|
||||||
|
## Registering Search Providers
|
||||||
|
|
||||||
|
### Basic Search Provider
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Search;
|
||||||
|
|
||||||
|
use Core\Admin\Search\Contracts\SearchProvider;
|
||||||
|
use Core\Admin\Search\SearchResult;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
class PostSearchProvider implements SearchProvider
|
||||||
|
{
|
||||||
|
public function search(string $query): array
|
||||||
|
{
|
||||||
|
return Post::where('title', 'like', "%{$query}%")
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(fn (Post $post) => new SearchResult(
|
||||||
|
title: $post->title,
|
||||||
|
description: $post->excerpt,
|
||||||
|
url: route('admin.blog.posts.edit', $post),
|
||||||
|
icon: 'document-text',
|
||||||
|
category: 'Blog Posts'
|
||||||
|
))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategory(): string
|
||||||
|
{
|
||||||
|
return 'Blog';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 50; // Higher = appears first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register in Boot.php
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Events\AdminPanelBooting;
|
||||||
|
use Mod\Blog\Search\PostSearchProvider;
|
||||||
|
|
||||||
|
class Boot
|
||||||
|
{
|
||||||
|
public static array $listens = [
|
||||||
|
AdminPanelBooting::class => 'onAdminPanel',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
$event->search(new PostSearchProvider());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Result
|
||||||
|
|
||||||
|
The `SearchResult` class defines how results appear:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Admin\Search\SearchResult;
|
||||||
|
|
||||||
|
new SearchResult(
|
||||||
|
title: 'My Blog Post', // Required
|
||||||
|
description: 'This is a blog post about...', // Optional
|
||||||
|
url: route('admin.blog.posts.edit', $post), // Required
|
||||||
|
icon: 'document-text', // Optional
|
||||||
|
category: 'Blog Posts', // Optional
|
||||||
|
metadata: [ // Optional
|
||||||
|
'Status' => 'Published',
|
||||||
|
'Author' => $post->author->name,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
- `title` (string, required) - Primary title
|
||||||
|
- `description` (string, optional) - Subtitle/excerpt
|
||||||
|
- `url` (string, required) - Link URL
|
||||||
|
- `icon` (string, optional) - Heroicon name
|
||||||
|
- `category` (string, optional) - Result category
|
||||||
|
- `metadata` (array, optional) - Additional key-value pairs
|
||||||
|
|
||||||
|
## Advanced Search Providers
|
||||||
|
|
||||||
|
### With Highlighting
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function search(string $query): array
|
||||||
|
{
|
||||||
|
return Post::where('title', 'like', "%{$query}%")
|
||||||
|
->get()
|
||||||
|
->map(function (Post $post) use ($query) {
|
||||||
|
// Highlight matching text
|
||||||
|
$title = str_ireplace(
|
||||||
|
$query,
|
||||||
|
"<mark>{$query}</mark>",
|
||||||
|
$post->title
|
||||||
|
);
|
||||||
|
|
||||||
|
return new SearchResult(
|
||||||
|
title: $title,
|
||||||
|
description: $post->excerpt,
|
||||||
|
url: route('admin.blog.posts.edit', $post),
|
||||||
|
icon: 'document-text'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Field Search
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function search(string $query): array
|
||||||
|
{
|
||||||
|
return Post::where(function ($q) use ($query) {
|
||||||
|
$q->where('title', 'like', "%{$query}%")
|
||||||
|
->orWhere('content', 'like', "%{$query}%")
|
||||||
|
->orWhere('slug', 'like', "%{$query}%");
|
||||||
|
})
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(fn ($post) => new SearchResult(
|
||||||
|
title: $post->title,
|
||||||
|
description: "Slug: {$post->slug}",
|
||||||
|
url: route('admin.blog.posts.edit', $post),
|
||||||
|
icon: 'document-text',
|
||||||
|
category: 'Posts'
|
||||||
|
))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Relevance Scoring
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function search(string $query): array
|
||||||
|
{
|
||||||
|
$posts = Post::selectRaw("
|
||||||
|
*,
|
||||||
|
CASE
|
||||||
|
WHEN title LIKE ? THEN 3
|
||||||
|
WHEN excerpt LIKE ? THEN 2
|
||||||
|
WHEN content LIKE ? THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END as relevance
|
||||||
|
", ["%{$query}%", "%{$query}%", "%{$query}%"])
|
||||||
|
->having('relevance', '>', 0)
|
||||||
|
->orderBy('relevance', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $posts->map(fn ($post) => new SearchResult(
|
||||||
|
title: $post->title,
|
||||||
|
description: $post->excerpt,
|
||||||
|
url: route('admin.blog.posts.edit', $post),
|
||||||
|
icon: 'document-text'
|
||||||
|
))->toArray();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search with Relationships
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function search(string $query): array
|
||||||
|
{
|
||||||
|
return Post::with('author', 'category')
|
||||||
|
->where('title', 'like', "%{$query}%")
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(fn ($post) => new SearchResult(
|
||||||
|
title: $post->title,
|
||||||
|
description: $post->excerpt,
|
||||||
|
url: route('admin.blog.posts.edit', $post),
|
||||||
|
icon: 'document-text',
|
||||||
|
category: 'Posts',
|
||||||
|
metadata: [
|
||||||
|
'Author' => $post->author->name,
|
||||||
|
'Category' => $post->category->name,
|
||||||
|
'Status' => ucfirst($post->status),
|
||||||
|
]
|
||||||
|
))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Analytics
|
||||||
|
|
||||||
|
Track search queries:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Search;
|
||||||
|
|
||||||
|
use Core\Admin\Search\Contracts\SearchProvider;
|
||||||
|
use Core\Admin\Search\SearchResult;
|
||||||
|
use Core\Search\Analytics\SearchAnalytics;
|
||||||
|
|
||||||
|
class PostSearchProvider implements SearchProvider
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected SearchAnalytics $analytics
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function search(string $query): array
|
||||||
|
{
|
||||||
|
// Record search
|
||||||
|
$this->analytics->recordSearch($query, 'admin', 'posts');
|
||||||
|
|
||||||
|
$results = Post::where('title', 'like', "%{$query}%")
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Record result count
|
||||||
|
$this->analytics->recordResults($query, $results->count());
|
||||||
|
|
||||||
|
return $results->map(fn ($post) => new SearchResult(
|
||||||
|
title: $post->title,
|
||||||
|
url: route('admin.blog.posts.edit', $post)
|
||||||
|
))->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Providers
|
||||||
|
|
||||||
|
Register multiple providers for different resources:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
$event->search(new PostSearchProvider());
|
||||||
|
$event->search(new CategorySearchProvider());
|
||||||
|
$event->search(new CommentSearchProvider());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each provider returns results independently, grouped by category.
|
||||||
|
|
||||||
|
## Search UI
|
||||||
|
|
||||||
|
The global search is accessible via:
|
||||||
|
|
||||||
|
### Keyboard Shortcut
|
||||||
|
|
||||||
|
Press `Cmd+K` (Mac) or `Ctrl+K` (Windows/Linux) to open search from anywhere in the admin panel.
|
||||||
|
|
||||||
|
### Search Button
|
||||||
|
|
||||||
|
Click the search icon in the admin header.
|
||||||
|
|
||||||
|
### Direct URL
|
||||||
|
|
||||||
|
Navigate to `/admin/search?q=query`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/admin.php
|
||||||
|
'search' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'min_length' => 2, // Minimum query length
|
||||||
|
'limit' => 10, // Results per provider
|
||||||
|
'debounce' => 300, // Debounce delay (ms)
|
||||||
|
'show_empty_results' => true,
|
||||||
|
'shortcuts' => [
|
||||||
|
'mac' => 'cmd+k',
|
||||||
|
'windows' => 'ctrl+k',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Suggestions
|
||||||
|
|
||||||
|
Provide autocomplete suggestions:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getSuggestions(string $query): array
|
||||||
|
{
|
||||||
|
return Post::where('title', 'like', "{$query}%")
|
||||||
|
->limit(5)
|
||||||
|
->pluck('title')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empty State
|
||||||
|
|
||||||
|
Customize empty search results:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getEmptyMessage(string $query): string
|
||||||
|
{
|
||||||
|
return "No posts found matching '{$query}'. Try a different search term.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmptyActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'label' => 'Create New Post',
|
||||||
|
'url' => route('admin.blog.posts.create'),
|
||||||
|
'icon' => 'plus',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Limit Results
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - limit results
|
||||||
|
return Post::where('title', 'like', "%{$query}%")
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// ❌ Bad - return all results
|
||||||
|
return Post::where('title', 'like', "%{$query}%")->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Indexes
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - indexed column
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->index('title');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Search Multiple Fields
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - comprehensive search
|
||||||
|
Post::where('title', 'like', "%{$query}%")
|
||||||
|
->orWhere('excerpt', 'like', "%{$query}%")
|
||||||
|
->orWhere('slug', 'like', "%{$query}%");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Include Context in Results
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - helpful metadata
|
||||||
|
new SearchResult(
|
||||||
|
title: $post->title,
|
||||||
|
description: $post->excerpt,
|
||||||
|
metadata: [
|
||||||
|
'Author' => $post->author->name,
|
||||||
|
'Date' => $post->created_at->format('M d, Y'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Set Priority
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Good - important resources first
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 100; // Posts appear before comments
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Admin;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
use Mod\Blog\Search\PostSearchProvider;
|
||||||
|
|
||||||
|
class PostSearchTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_searches_posts(): void
|
||||||
|
{
|
||||||
|
Post::factory()->create(['title' => 'Laravel Framework']);
|
||||||
|
Post::factory()->create(['title' => 'Vue.js Guide']);
|
||||||
|
|
||||||
|
$provider = new PostSearchProvider();
|
||||||
|
$results = $provider->search('Laravel');
|
||||||
|
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertEquals('Laravel Framework', $results[0]->title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_limits_results(): void
|
||||||
|
{
|
||||||
|
Post::factory()->count(10)->create([
|
||||||
|
'title' => 'Test Post',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$provider = new PostSearchProvider();
|
||||||
|
$results = $provider->search('Test');
|
||||||
|
|
||||||
|
$this->assertLessThanOrEqual(5, count($results));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Search Analytics →](/packages/core/search)
|
||||||
|
- [Admin Menus →](/packages/admin/menus)
|
||||||
|
- [Livewire Components →](/packages/admin/modals)
|
||||||
Loading…
Add table
Reference in a new issue