docs: add package documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 10:47:50 +00:00
parent 63f274f83a
commit a91849fb53
10 changed files with 5939 additions and 0 deletions

559
docs/authorization.md Normal file
View 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)

View 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
View 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)

View 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
View 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
View 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">&larr; Previous: Setup</a>
</div>
<div>
<a href="/next" class="text-blue-600">Next: Installation &rarr;</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 &lt;john@example.com&gt;</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
View 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
View 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
View 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
View 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)