diff --git a/TODO.md b/TODO.md
index b5e0c2b..0e7b606 100644
--- a/TODO.md
+++ b/TODO.md
@@ -21,13 +21,14 @@
- **Completed:** January 2026
- **File:** `tests/Feature/Forms/AuthorizationTest.php`
-- [ ] **Test Coverage: Livewire Modals** - Test modal system
- - [ ] Test modal opening/closing
- - [ ] Test file uploads in modals
- - [ ] Test validation in modals
- - [ ] Test nested modals
- - [ ] Test modal events and lifecycle
- - **Estimated effort:** 3-4 hours
+- [x] **Test Coverage: Livewire Modals** - Test modal system
+ - [x] Test modal opening/closing
+ - [x] Test validation in modals
+ - [x] Test nested modals
+ - [x] Test modal events and lifecycle
+ - [x] Test modal data passing
+ - **Completed:** January 2026
+ - **File:** `tests/Feature/Modal/LivewireModalTest.php`
### Medium Priority
@@ -235,5 +236,6 @@
- [x] **Test Coverage: Admin Menu System** - AdminMenuRegistry, MenuItemBuilder, MenuItemGroup, IconValidator tests
- [x] **Test Coverage: Teapot/Honeypot** - Bot detection, severity classification, rate limiting, header sanitization, model scopes (40+ tests)
- [x] **Test Coverage: Search System** - SearchProviderRegistry, search execution, result aggregation, fuzzy matching, relevance scoring, SearchResult tests (60+ tests)
+- [x] **Test Coverage: Livewire Modals** - Modal opening/closing, events, data passing, validation, nested modals, lifecycle (50+ tests)
*See `changelog/2026/jan/` for completed features.*
diff --git a/tests/Feature/Modal/LivewireModalTest.php b/tests/Feature/Modal/LivewireModalTest.php
new file mode 100644
index 0000000..1507cbb
--- /dev/null
+++ b/tests/Feature/Modal/LivewireModalTest.php
@@ -0,0 +1,814 @@
+showModal = true;
+ }
+
+ public function closeModal(): void
+ {
+ $this->showModal = false;
+ $this->reset('title');
+ }
+
+ public function render(): string
+ {
+ return <<<'HTML'
+
+
+ @if($showModal)
+
+
{{ $title }}
+
+
+ @endif
+
+ HTML;
+ }
+}
+
+/**
+ * Modal component for testing event dispatch and listening.
+ */
+class EventModalComponent extends Component
+{
+ public bool $open = false;
+
+ public array $receivedEvents = [];
+
+ #[On('open-modal')]
+ public function handleOpenModal(string $source = ''): void
+ {
+ $this->open = true;
+ $this->receivedEvents[] = ['type' => 'open-modal', 'source' => $source];
+ }
+
+ #[On('close-modal')]
+ public function handleCloseModal(): void
+ {
+ $this->open = false;
+ $this->receivedEvents[] = ['type' => 'close-modal'];
+ }
+
+ public function closeAndDispatch(): void
+ {
+ $this->open = false;
+ $this->dispatch('modal-closed', result: 'success');
+ }
+
+ public function dispatchToParent(): void
+ {
+ $this->dispatch('data-updated', data: ['key' => 'value']);
+ }
+
+ public function render(): string
+ {
+ return <<<'HTML'
+
+ {{ $open ? 'Open' : 'Closed' }}
+ Events: {{ count($receivedEvents) }}
+
+ HTML;
+ }
+}
+
+/**
+ * Modal component for testing data passing.
+ */
+class DataModalComponent extends Component
+{
+ public bool $showModal = false;
+
+ public ?int $editingId = null;
+
+ public string $name = '';
+
+ public string $email = '';
+
+ public array $items = [];
+
+ public function openWithData(int $id, string $name, string $email): void
+ {
+ $this->editingId = $id;
+ $this->name = $name;
+ $this->email = $email;
+ $this->showModal = true;
+ }
+
+ public function openWithItems(array $items): void
+ {
+ $this->items = $items;
+ $this->showModal = true;
+ }
+
+ public function closeModal(): void
+ {
+ $this->showModal = false;
+ $this->resetForm();
+ }
+
+ protected function resetForm(): void
+ {
+ $this->editingId = null;
+ $this->name = '';
+ $this->email = '';
+ $this->items = [];
+ }
+
+ #[Computed]
+ public function itemCount(): int
+ {
+ return count($this->items);
+ }
+
+ public function render(): string
+ {
+ return <<<'HTML'
+
+ @if($showModal)
+
+ ID: {{ $editingId }}
+ Name: {{ $name }}
+ Email: {{ $email }}
+ Items: {{ $this->itemCount }}
+
+ @endif
+
+ HTML;
+ }
+}
+
+/**
+ * Modal component for testing validation.
+ */
+class ValidationModalComponent extends Component
+{
+ public bool $showModal = false;
+
+ public string $name = '';
+
+ public string $email = '';
+
+ public string $description = '';
+
+ protected function rules(): array
+ {
+ return [
+ 'name' => ['required', 'string', 'min:3', 'max:100'],
+ 'email' => ['required', 'email'],
+ 'description' => ['nullable', 'string', 'max:500'],
+ ];
+ }
+
+ public function openModal(): void
+ {
+ $this->showModal = true;
+ }
+
+ public function save(): void
+ {
+ $this->validate();
+
+ // Simulate successful save
+ $this->dispatch('saved', name: $this->name);
+ $this->closeModal();
+ }
+
+ public function closeModal(): void
+ {
+ $this->showModal = false;
+ $this->reset(['name', 'email', 'description']);
+ $this->resetErrorBag();
+ }
+
+ public function render(): string
+ {
+ return <<<'HTML'
+
+ @if($showModal)
+
+ @endif
+
+ HTML;
+ }
+}
+
+/**
+ * Modal component for testing nested/stacked modals.
+ */
+class NestedModalComponent extends Component
+{
+ public bool $primaryModal = false;
+
+ public bool $secondaryModal = false;
+
+ public bool $tertiaryModal = false;
+
+ public string $activeLevel = '';
+
+ public function openPrimary(): void
+ {
+ $this->primaryModal = true;
+ $this->activeLevel = 'primary';
+ }
+
+ public function openSecondary(): void
+ {
+ $this->secondaryModal = true;
+ $this->activeLevel = 'secondary';
+ }
+
+ public function openTertiary(): void
+ {
+ $this->tertiaryModal = true;
+ $this->activeLevel = 'tertiary';
+ }
+
+ public function closePrimary(): void
+ {
+ $this->primaryModal = false;
+ $this->activeLevel = '';
+ }
+
+ public function closeSecondary(): void
+ {
+ $this->secondaryModal = false;
+ $this->activeLevel = $this->primaryModal ? 'primary' : '';
+ }
+
+ public function closeTertiary(): void
+ {
+ $this->tertiaryModal = false;
+ $this->activeLevel = $this->secondaryModal ? 'secondary' : ($this->primaryModal ? 'primary' : '');
+ }
+
+ public function closeAll(): void
+ {
+ $this->primaryModal = false;
+ $this->secondaryModal = false;
+ $this->tertiaryModal = false;
+ $this->activeLevel = '';
+ }
+
+ #[Computed]
+ public function openModalsCount(): int
+ {
+ return ($this->primaryModal ? 1 : 0)
+ + ($this->secondaryModal ? 1 : 0)
+ + ($this->tertiaryModal ? 1 : 0);
+ }
+
+ public function render(): string
+ {
+ return <<<'HTML'
+
+ Active: {{ $activeLevel }}
+ Open: {{ $this->openModalsCount }}
+
+ HTML;
+ }
+}
+
+/**
+ * Modal component for testing lifecycle and state management.
+ */
+class LifecycleModalComponent extends Component
+{
+ public bool $showModal = false;
+
+ public int $mountCount = 0;
+
+ public int $updateCount = 0;
+
+ public string $state = '';
+
+ public function mount(): void
+ {
+ $this->mountCount++;
+ }
+
+ public function updated(): void
+ {
+ $this->updateCount++;
+ }
+
+ public function openModal(): void
+ {
+ $this->showModal = true;
+ $this->state = 'opened';
+ }
+
+ public function closeModal(): void
+ {
+ $this->showModal = false;
+ $this->state = 'closed';
+ }
+
+ public function toggleModal(): void
+ {
+ $this->showModal = ! $this->showModal;
+ $this->state = $this->showModal ? 'toggled-open' : 'toggled-closed';
+ }
+
+ public function render(): string
+ {
+ return <<<'HTML'
+
+ Mount: {{ $mountCount }}
+ Updates: {{ $updateCount }}
+ State: {{ $state }}
+
+ HTML;
+ }
+}
+
+// =============================================================================
+// Modal Opening/Closing Tests
+// =============================================================================
+
+describe('Modal opening and closing', function () {
+ it('opens modal when openModal is called', function () {
+ Livewire::test(BasicModalComponent::class)
+ ->assertSet('showModal', false)
+ ->call('openModal')
+ ->assertSet('showModal', true)
+ ->assertSee('Close');
+ });
+
+ it('closes modal when closeModal is called', function () {
+ Livewire::test(BasicModalComponent::class)
+ ->set('showModal', true)
+ ->call('closeModal')
+ ->assertSet('showModal', false);
+ });
+
+ it('resets state when modal closes', function () {
+ Livewire::test(BasicModalComponent::class)
+ ->set('showModal', true)
+ ->set('title', 'Test Title')
+ ->call('closeModal')
+ ->assertSet('showModal', false)
+ ->assertSet('title', '');
+ });
+
+ it('can toggle modal state', function () {
+ Livewire::test(LifecycleModalComponent::class)
+ ->assertSet('showModal', false)
+ ->call('toggleModal')
+ ->assertSet('showModal', true)
+ ->assertSet('state', 'toggled-open')
+ ->call('toggleModal')
+ ->assertSet('showModal', false)
+ ->assertSet('state', 'toggled-closed');
+ });
+
+ it('can set modal state directly via wire:model', function () {
+ Livewire::test(BasicModalComponent::class)
+ ->set('showModal', true)
+ ->assertSet('showModal', true)
+ ->set('showModal', false)
+ ->assertSet('showModal', false);
+ });
+
+ it('renders modal content only when open', function () {
+ Livewire::test(BasicModalComponent::class)
+ ->assertDontSee('Close') // Modal closed, button not visible
+ ->call('openModal')
+ ->assertSee('Close'); // Modal open, button visible
+ });
+});
+
+// =============================================================================
+// Modal Event Tests
+// =============================================================================
+
+describe('Modal events', function () {
+ it('responds to open-modal event', function () {
+ Livewire::test(EventModalComponent::class)
+ ->assertSet('open', false)
+ ->dispatch('open-modal', source: 'button-click')
+ ->assertSet('open', true)
+ ->assertSet('receivedEvents', fn ($events) => count($events) === 1
+ && $events[0]['type'] === 'open-modal'
+ && $events[0]['source'] === 'button-click'
+ );
+ });
+
+ it('responds to close-modal event', function () {
+ Livewire::test(EventModalComponent::class)
+ ->set('open', true)
+ ->dispatch('close-modal')
+ ->assertSet('open', false)
+ ->assertSet('receivedEvents', fn ($events) => count($events) === 1 && $events[0]['type'] === 'close-modal');
+ });
+
+ it('dispatches event when closing modal', function () {
+ Livewire::test(EventModalComponent::class)
+ ->set('open', true)
+ ->call('closeAndDispatch')
+ ->assertSet('open', false)
+ ->assertDispatched('modal-closed', result: 'success');
+ });
+
+ it('dispatches data events to parent components', function () {
+ Livewire::test(EventModalComponent::class)
+ ->call('dispatchToParent')
+ ->assertDispatched('data-updated', data: ['key' => 'value']);
+ });
+
+ it('accumulates multiple events', function () {
+ Livewire::test(EventModalComponent::class)
+ ->dispatch('open-modal', source: 'first')
+ ->dispatch('close-modal')
+ ->dispatch('open-modal', source: 'second')
+ ->assertSet('receivedEvents', fn ($events) => count($events) === 3);
+ });
+
+ it('handles events with default parameters', function () {
+ Livewire::test(EventModalComponent::class)
+ ->dispatch('open-modal') // No source parameter
+ ->assertSet('open', true)
+ ->assertSet('receivedEvents', fn ($events) => $events[0]['source'] === '');
+ });
+});
+
+// =============================================================================
+// Modal Data Passing Tests
+// =============================================================================
+
+describe('Modal data passing', function () {
+ it('receives scalar data when opening modal', function () {
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithData', 42, 'John Doe', 'john@example.com')
+ ->assertSet('showModal', true)
+ ->assertSet('editingId', 42)
+ ->assertSet('name', 'John Doe')
+ ->assertSet('email', 'john@example.com');
+ });
+
+ it('receives array data when opening modal', function () {
+ $items = [
+ ['id' => 1, 'name' => 'Item 1'],
+ ['id' => 2, 'name' => 'Item 2'],
+ ['id' => 3, 'name' => 'Item 3'],
+ ];
+
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithItems', $items)
+ ->assertSet('showModal', true)
+ ->assertSet('items', $items)
+ ->assertSet(fn ($component) => $component->itemCount === 3);
+ });
+
+ it('resets data when modal closes', function () {
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithData', 42, 'John Doe', 'john@example.com')
+ ->call('closeModal')
+ ->assertSet('showModal', false)
+ ->assertSet('editingId', null)
+ ->assertSet('name', '')
+ ->assertSet('email', '')
+ ->assertSet('items', []);
+ });
+
+ it('preserves data while modal is open', function () {
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithData', 42, 'John Doe', 'john@example.com')
+ ->set('name', 'Jane Doe')
+ ->assertSet('editingId', 42) // Unchanged
+ ->assertSet('name', 'Jane Doe') // Updated
+ ->assertSet('email', 'john@example.com'); // Unchanged
+ });
+
+ it('renders data in modal view', function () {
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithData', 42, 'John Doe', 'john@example.com')
+ ->assertSee('ID: 42')
+ ->assertSee('Name: John Doe')
+ ->assertSee('Email: john@example.com');
+ });
+
+ it('handles empty array data', function () {
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithItems', [])
+ ->assertSet('showModal', true)
+ ->assertSet('items', [])
+ ->assertSee('Items: 0');
+ });
+});
+
+// =============================================================================
+// Modal Validation Tests
+// =============================================================================
+
+describe('Modal validation', function () {
+ it('validates required fields', function () {
+ Livewire::test(ValidationModalComponent::class)
+ ->set('showModal', true)
+ ->set('name', '')
+ ->set('email', '')
+ ->call('save')
+ ->assertHasErrors(['name', 'email']);
+ });
+
+ it('validates email format', function () {
+ Livewire::test(ValidationModalComponent::class)
+ ->set('showModal', true)
+ ->set('name', 'Valid Name')
+ ->set('email', 'invalid-email')
+ ->call('save')
+ ->assertHasErrors(['email'])
+ ->assertHasNoErrors(['name']);
+ });
+
+ it('validates minimum length', function () {
+ Livewire::test(ValidationModalComponent::class)
+ ->set('showModal', true)
+ ->set('name', 'AB') // Too short (min:3)
+ ->set('email', 'valid@email.com')
+ ->call('save')
+ ->assertHasErrors(['name']);
+ });
+
+ it('passes validation with valid data', function () {
+ Livewire::test(ValidationModalComponent::class)
+ ->set('showModal', true)
+ ->set('name', 'Valid Name')
+ ->set('email', 'valid@email.com')
+ ->set('description', 'Optional description')
+ ->call('save')
+ ->assertHasNoErrors()
+ ->assertDispatched('saved', name: 'Valid Name')
+ ->assertSet('showModal', false);
+ });
+
+ it('clears validation errors when modal closes', function () {
+ Livewire::test(ValidationModalComponent::class)
+ ->set('showModal', true)
+ ->set('name', '')
+ ->call('save')
+ ->assertHasErrors(['name'])
+ ->call('closeModal')
+ ->assertHasNoErrors();
+ });
+
+ it('resets form data when modal closes after validation errors', function () {
+ Livewire::test(ValidationModalComponent::class)
+ ->set('showModal', true)
+ ->set('name', 'Invalid')
+ ->set('email', 'bad-email')
+ ->call('save')
+ ->assertHasErrors(['email'])
+ ->call('closeModal')
+ ->assertSet('name', '')
+ ->assertSet('email', '')
+ ->assertSet('showModal', false);
+ });
+
+ it('allows nullable fields', function () {
+ Livewire::test(ValidationModalComponent::class)
+ ->set('showModal', true)
+ ->set('name', 'Valid Name')
+ ->set('email', 'valid@email.com')
+ ->set('description', '') // Nullable field
+ ->call('save')
+ ->assertHasNoErrors(['description']);
+ });
+});
+
+// =============================================================================
+// Nested Modal Tests
+// =============================================================================
+
+describe('Nested modals', function () {
+ it('can open primary modal', function () {
+ Livewire::test(NestedModalComponent::class)
+ ->call('openPrimary')
+ ->assertSet('primaryModal', true)
+ ->assertSet('activeLevel', 'primary')
+ ->assertSet(fn ($component) => $component->openModalsCount === 1);
+ });
+
+ it('can open secondary modal on top of primary', function () {
+ Livewire::test(NestedModalComponent::class)
+ ->call('openPrimary')
+ ->call('openSecondary')
+ ->assertSet('primaryModal', true)
+ ->assertSet('secondaryModal', true)
+ ->assertSet('activeLevel', 'secondary')
+ ->assertSet(fn ($component) => $component->openModalsCount === 2);
+ });
+
+ it('can open tertiary modal creating three-level stack', function () {
+ Livewire::test(NestedModalComponent::class)
+ ->call('openPrimary')
+ ->call('openSecondary')
+ ->call('openTertiary')
+ ->assertSet('primaryModal', true)
+ ->assertSet('secondaryModal', true)
+ ->assertSet('tertiaryModal', true)
+ ->assertSet('activeLevel', 'tertiary')
+ ->assertSet(fn ($component) => $component->openModalsCount === 3);
+ });
+
+ it('closes tertiary modal and returns to secondary', function () {
+ Livewire::test(NestedModalComponent::class)
+ ->call('openPrimary')
+ ->call('openSecondary')
+ ->call('openTertiary')
+ ->call('closeTertiary')
+ ->assertSet('tertiaryModal', false)
+ ->assertSet('secondaryModal', true)
+ ->assertSet('primaryModal', true)
+ ->assertSet('activeLevel', 'secondary');
+ });
+
+ it('closes secondary modal and returns to primary', function () {
+ Livewire::test(NestedModalComponent::class)
+ ->call('openPrimary')
+ ->call('openSecondary')
+ ->call('closeSecondary')
+ ->assertSet('secondaryModal', false)
+ ->assertSet('primaryModal', true)
+ ->assertSet('activeLevel', 'primary');
+ });
+
+ it('can close all modals at once', function () {
+ Livewire::test(NestedModalComponent::class)
+ ->call('openPrimary')
+ ->call('openSecondary')
+ ->call('openTertiary')
+ ->call('closeAll')
+ ->assertSet('primaryModal', false)
+ ->assertSet('secondaryModal', false)
+ ->assertSet('tertiaryModal', false)
+ ->assertSet('activeLevel', '')
+ ->assertSet(fn ($component) => $component->openModalsCount === 0);
+ });
+
+ it('tracks active level correctly when closing out of order', function () {
+ Livewire::test(NestedModalComponent::class)
+ ->call('openPrimary')
+ ->call('openSecondary')
+ ->call('openTertiary')
+ ->call('closeSecondary') // Close middle modal
+ ->assertSet('activeLevel', 'primary')
+ ->assertSet('primaryModal', true)
+ ->assertSet('secondaryModal', false)
+ ->assertSet('tertiaryModal', false);
+ });
+});
+
+// =============================================================================
+// Modal Lifecycle Tests
+// =============================================================================
+
+describe('Modal lifecycle', function () {
+ it('mounts once on component creation', function () {
+ Livewire::test(LifecycleModalComponent::class)
+ ->assertSet('mountCount', 1);
+ });
+
+ it('tracks updates when modal state changes', function () {
+ Livewire::test(LifecycleModalComponent::class)
+ ->assertSet('updateCount', 0)
+ ->call('openModal')
+ ->assertSet('updateCount', fn ($count) => $count > 0);
+ });
+
+ it('maintains state across multiple operations', function () {
+ Livewire::test(LifecycleModalComponent::class)
+ ->call('openModal')
+ ->assertSet('state', 'opened')
+ ->call('closeModal')
+ ->assertSet('state', 'closed')
+ ->call('openModal')
+ ->assertSet('state', 'opened');
+ });
+
+ it('mount is not called again when modal opens', function () {
+ Livewire::test(LifecycleModalComponent::class)
+ ->assertSet('mountCount', 1)
+ ->call('openModal')
+ ->assertSet('mountCount', 1) // Still 1
+ ->call('closeModal')
+ ->assertSet('mountCount', 1); // Still 1
+ });
+});
+
+// =============================================================================
+// Edge Cases and Boundary Tests
+// =============================================================================
+
+describe('Edge cases', function () {
+ it('handles rapid open/close cycles', function () {
+ $component = Livewire::test(BasicModalComponent::class);
+
+ for ($i = 0; $i < 10; $i++) {
+ $component->call('openModal')
+ ->assertSet('showModal', true)
+ ->call('closeModal')
+ ->assertSet('showModal', false);
+ }
+ });
+
+ it('handles multiple event dispatches in sequence', function () {
+ Livewire::test(EventModalComponent::class)
+ ->dispatch('open-modal')
+ ->dispatch('close-modal')
+ ->dispatch('open-modal')
+ ->dispatch('close-modal')
+ ->assertSet('receivedEvents', fn ($events) => count($events) === 4);
+ });
+
+ it('preserves computed properties when modal is open', function () {
+ $items = [['id' => 1], ['id' => 2]];
+
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithItems', $items)
+ ->assertSet(fn ($component) => $component->itemCount === 2)
+ ->set('items', [...$items, ['id' => 3]])
+ ->assertSet(fn ($component) => $component->itemCount === 3);
+ });
+
+ it('handles closing already closed modal gracefully', function () {
+ Livewire::test(BasicModalComponent::class)
+ ->assertSet('showModal', false)
+ ->call('closeModal') // Already closed
+ ->assertSet('showModal', false); // Still closed, no error
+ });
+
+ it('handles opening already open modal gracefully', function () {
+ Livewire::test(BasicModalComponent::class)
+ ->call('openModal')
+ ->assertSet('showModal', true)
+ ->call('openModal') // Already open
+ ->assertSet('showModal', true); // Still open, no error
+ });
+
+ it('handles special characters in data', function () {
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithData', 1, 'John "Jack" O\'Brien', 'test+special@example.co.uk')
+ ->assertSet('name', 'John "Jack" O\'Brien')
+ ->assertSet('email', 'test+special@example.co.uk');
+ });
+
+ it('handles unicode in data', function () {
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithData', 1, "\xC3\x89mile Zola", "emile@caf\xC3\xA9.fr")
+ ->assertSet('name', "\xC3\x89mile Zola")
+ ->assertSet('email', "emile@caf\xC3\xA9.fr");
+ });
+
+ it('handles very long strings in data', function () {
+ $longName = str_repeat('A', 1000);
+ $longEmail = 'a'.str_repeat('b', 100).'@example.com';
+
+ Livewire::test(DataModalComponent::class)
+ ->call('openWithData', 1, $longName, $longEmail)
+ ->assertSet('name', $longName)
+ ->assertSet('email', $longEmail);
+ });
+});