query->replace(['step' => $query, 'delivery_step' => 'form.delivery.wizard.details']); Schema::make(Livewire::make())->key('form')->components([ $profile = Wizard::make([ Step::make('Details')->key('details')->id('profile-details'), Step::make('Contact')->key('contact')->id('profile-contact'), ])->key('wizard')->persistStepInQueryString()->startOnStep(2), Group::make([ $delivery = Wizard::make([ Step::make('Hidden')->hidden(), Step::make('Details')->key('details')->id('delivery-details'), Step::make('Contact')->key('contact')->id('delivery-contact'), ])->key('wizard')->persistStepInQueryString('delivery_step')->startOnStep(2), ])->key('delivery'), ])->fill(); expect($profile->getStartStep())->toBe($expected) ->and($delivery->getStartStep())->toBe(1) ->and($profile->toHtml())->toContain('profile-details') ->and($delivery->toHtml())->toContain('delivery-details'); })->with([ 'absolute key' => ['form.wizard.details', 1], 'legacy custom ID' => ['profile-details', 1], 'relative key is not a persisted step key' => ['details', 2], 'another container' => ['form.delivery.wizard.details', 2], 'stale key' => ['removed', 2], 'missing value' => [null, 2], 'array value' => [['form.wizard.details'], 2], ]); it('prefers query-string step keys before legacy IDs', function (string $query, int $expected): void { request()->query->replace(['step' => $query]); Schema::make(Livewire::make())->key('form')->components([ $wizard = Wizard::make([ Step::make('Details')->key('details')->id('form.wizard.contact'), Step::make('Contact')->key('contact')->id('profile-contact'), ])->key('wizard')->persistStepInQueryString(), ])->fill(); expect($wizard->getStartStep())->toBe($expected); })->with([ 'absolute key before another step custom ID' => ['form.wizard.contact', 2], 'legacy custom ID' => ['profile-contact', 2], ]); it('persists independent wizards across reload and browser history', function (): void { $this->actingAs(User::factory()->create()); $browser = visit('/wizard-browser-test') ->assertVisible('#profile-details') ->assertVisible('#delivery-details') ->click('[data-testid="wizard-next-action"]') ->assertVisible('#profile-contact') ->assertVisible('#delivery-details') ->click('[data-testid="delivery-next-action"]') ->assertVisible('#delivery-contact'); expect($browser->script('new URL(location.href).searchParams.get("step")'))->toBe('form.wizard.contact'); expect($browser->script('new URL(location.href).searchParams.get("delivery_step")'))->toBe('form.delivery.wizard.contact'); $browser->script("Livewire.navigate('/tabs-browser-test')"); $browser->assertVisible('#profile-tabs') ->back()->assertVisible('#profile-contact')->assertVisible('#delivery-contact') ->forward()->assertVisible('#profile-tabs') ->back()->assertVisible('#profile-contact')->assertVisible('#delivery-contact') ->click('[data-testid="wizard-next-action"]') ->assertVisible('#profile-review')->assertVisible('#delivery-contact'); expect($browser->script('new URL(location.href).searchParams.get("step")'))->toBe('form.wizard.review'); $browser->refresh()->assertVisible('#profile-review')->assertVisible('#delivery-contact') ->assertNoAccessibilityIssues() ->navigate('/wizard-browser-test?step=form.wizard.details&delivery_step=form.delivery.wizard.details') ->assertVisible('#profile-details')->assertVisible('#delivery-details') ->back()->assertVisible('#profile-review')->assertVisible('#delivery-contact') ->forward()->assertVisible('#profile-details')->assertVisible('#delivery-details') ->navigate('/wizard-browser-test?step=profile-contact&delivery_step=delivery-contact') ->assertVisible('#profile-contact')->assertVisible('#delivery-contact') ->navigate('/wizard-browser-test?step=removed&delivery_step=delivery-contact') ->assertVisible('#profile-details')->assertVisible('#delivery-contact') ->assertNoSmoke(); visit('/wizard-browser-test?step=form.wizard.contact&delivery_step=form.delivery.wizard.contact')->inDarkMode() ->assertVisible('#profile-contact')->assertVisible('#delivery-contact') ->assertNoAccessibilityIssues(); }); it('does not turn an unknown browser step into the first step', function (): void { $this->actingAs(User::factory()->create()); $browser = visit('/wizard-browser-test')->assertVisible('#profile-details'); expect($browser->script(<<<'JS' (() => { const wizard = Alpine.$data(document.querySelector('#profile-wizard')) wizard.goToStep('removed') return wizard.step })() JS))->toBe('form.wizard.details'); expect($browser->script(<<<'JS' (async () => { const wizard = Alpine.$data(document.querySelector('#profile-wizard')) wizard.step = 'removed' await wizard.requestNextStep() wizard.goToNextStep() return [wizard.getStepIndex('removed'), wizard.step] })() JS))->toBe([-1, 'removed']); $browser->assertMissing('#profile-contact')->assertNoSmoke(); }); it('advances to the resolved visible step when a validation hook hides its own step', function (): void { $this->actingAs(User::factory()->create()); $browser = visit('/wizard-browser-test?hide_contact_after_validation=1') ->assertVisible('#profile-details') ->click('[data-testid="wizard-next-action"]') ->assertVisible('#profile-contact') ->click('[data-testid="wizard-next-action"]') ->assertVisible('#profile-review') ->assertMissing('#profile-contact') ->hover('#profile-review') ->wait(0.3) ->assertNoAccessibilityIssues() ->assertNoSmoke(); expect($browser->script('new URL(location.href).searchParams.get("step")'))->toBe('form.wizard.review'); visit('/wizard-browser-test?step=form.wizard.review')->inDarkMode() ->assertVisible('#profile-review')->assertNoAccessibilityIssues(); }); it('rejects invalid `nextStep()` indexes before hooks or state changes', function (array $arguments, bool $skippable): void { $component = livewire(WizardTransitions::class, ['skippable' => $skippable]) ->call('callSchemaComponentMethod', 'form.wizard', 'nextStep', $arguments) ->assertNotDispatched('next-wizard-step') ->assertSet('hooks', []) ->assertSet('data.name', 'Ada') ->assertHasNoErrors(); expect($component->instance()->form->getComponent('wizard')->getCurrentStepIndex())->toBe(0); })->with([ 'missing' => [[]], 'null' => [['currentStepIndex' => null]], 'negative' => [['currentStepIndex' => -1]], 'numeric string' => [['currentStepIndex' => '0']], 'malformed string' => [['currentStepIndex' => 'invalid']], 'boolean' => [['currentStepIndex' => false]], 'float' => [['currentStepIndex' => 0.5]], 'array' => [['currentStepIndex' => []]], 'last' => [['currentStepIndex' => 2]], 'last plus one' => [['currentStepIndex' => 3]], 'overflow' => [['currentStepIndex' => PHP_INT_MAX]], ])->with([false, true]); it('rejects stale `nextStep()` indexes after visibility changes', function (int $index, string $key): void { livewire(WizardTransitions::class) ->set('hideFirst', true) ->call('callSchemaComponentMethod', 'form.wizard', 'nextStep', [ 'currentStepIndex' => $index, 'currentStepKey' => $key, ]) ->assertSet('hooks', []) ->assertNotDispatched('next-wizard-step') ->assertHasNoErrors(); })->with([ 'in-range index now identifies another step' => [0, 'form.wizard.details'], 'old middle index is now last' => [1, 'form.wizard.contact'], 'old last index is out of range' => [2, 'form.wizard.review'], ]); it('validates only the current visible step before advancing', function (bool $hideFirst, string $expectedStep): void { $component = livewire(WizardTransitions::class, ['hideFirst' => $hideFirst]) ->call('callSchemaComponentMethod', 'form.wizard', 'nextStep', [ 'currentStepIndex' => 0, 'currentStepKey' => "form.wizard.{$expectedStep}", ]) ->assertSet('hooks', ["{$expectedStep}:before", "{$expectedStep}:after"]) ->assertDispatched('next-wizard-step', key: 'form.wizard') ->assertHasNoErrors(); expect($component->instance()->form->getComponent('wizard')->getCurrentStepIndex())->toBe(1); })->with([[false, 'details'], [true, 'contact']]); it('does not advance on validation failure or `Halt`', function (string $failure, array $expectedHooks): void { $component = livewire(WizardTransitions::class, ['failure' => $failure]) ->call('callSchemaComponentMethod', 'form.wizard', 'nextStep', ['currentStepIndex' => 0]) ->assertSet('hooks', $expectedHooks) ->assertNotDispatched('next-wizard-step'); if ($failure === 'validation') { $component->assertHasErrors(['data.name' => 'required']); } else { $component->assertHasNoErrors(); } expect($component->instance()->form->getComponent('wizard')->getCurrentStepIndex())->toBe(0); })->with([ ['validation', ['details:before']], ['before', ['details:before']], ['after', ['details:before', 'details:after']], ]); it('does not call a disabled or unauthorized next action', function (string $restriction): void { livewire(WizardTransitions::class, ['restriction' => $restriction]) ->call('callSchemaComponentMethod', 'form.wizard', 'nextStep', ['currentStepIndex' => 0]) ->assertSet('hooks', []) ->assertNotDispatched('next-wizard-step'); })->with(['disabled', 'unauthorized']); it('preserves valid index-only calls and skips validation only for `skippable()` wizards', function (): void { $component = livewire(WizardTransitions::class, ['skippable' => true, 'failure' => 'validation']) ->call('callSchemaComponentMethod', 'form.wizard', 'nextStep', ['currentStepIndex' => 0]) ->assertSet('hooks', []) ->assertDispatched('next-wizard-step', key: 'form.wizard') ->assertHasNoErrors(); expect($component->instance()->form->getComponent('wizard')->getCurrentStepIndex())->toBe(1); }); it('keeps the accepted later step on failure and validates it again on retry', function (string $failure, bool $withKey): void { $arguments = ['currentStepIndex' => 1]; if ($withKey) { $arguments['currentStepKey'] = 'form.wizard.contact'; } $component = livewire(WizardTransitions::class, ['failure' => $failure]) ->goToNextWizardStep() ->assertWizardCurrentStep(2) ->call('callSchemaComponentMethod', 'form.wizard', 'nextStep', $arguments) ->assertWizardCurrentStep(2) ->assertNotDispatched('next-wizard-step'); if ($failure === 'contact-validation') { $component->assertHasErrors(['data.contact_notes' => 'required']); } else { $component->assertHasNoErrors(); } $component->set('failure', '') ->call('callSchemaComponentMethod', 'form.wizard', 'nextStep', $arguments) ->assertWizardCurrentStep(3) ->assertDispatched('next-wizard-step', step: 'form.wizard.review') ->assertHasNoErrors(); expect(array_slice($component->get('hooks'), -2))->toBe(['contact:before', 'contact:after']); })->with(['contact-validation', 'contact-before', 'contact-after'])->with([false, true]); it('resolves and initializes the visible destination after hooks change the sequence', function (string $mutation, string $destination, int $index, string $field): void { $component = livewire(WizardTransitions::class, ['mutation' => $mutation]) ->set('data', ['name' => 'Ada']) ->call('callSchemaComponentMethod', 'form.wizard', 'nextStep', [ 'currentStepIndex' => 0, 'currentStepKey' => 'form.wizard.details', ]) ->assertSet('hooks', ['details:before', 'details:after']) ->assertDispatched('next-wizard-step', step: "form.wizard.{$destination}", currentStep: 'form.wizard.details') ->assertHasNoErrors(); expect($component->instance()->form->getComponent('wizard')->getCurrentStepIndex())->toBe($index) ->and($component->get('data'))->toBe(['name' => 'Ada', $field => null]); })->with([ ['hide-current', 'contact', 0, 'contact_notes'], ['hide-next', 'review', 1, 'review_notes'], ['replace-next', 'replacement', 1, 'replacement_notes'], ]); it('checks `previousStep()` bounds without changing valid backward navigation', function (array $arguments, int $expected): void { $component = livewire(WizardTransitions::class, ['startStep' => 3]) ->call('callSchemaComponentMethod', 'form.wizard', 'previousStep', $arguments) ->assertSet('hooks', []) ->assertNotDispatched('next-wizard-step') ->assertHasNoErrors(); expect($component->instance()->form->getComponent('wizard')->getCurrentStepIndex())->toBe($expected); })->with([ 'missing' => [[], 2], 'malformed' => [['currentStepIndex' => 'invalid'], 2], 'negative' => [['currentStepIndex' => -1], 2], 'first' => [['currentStepIndex' => 0], 2], 'last' => [['currentStepIndex' => 2], 1], 'past last' => [['currentStepIndex' => 3], 2], ]); it('can set `skippable()`', function (): void { $wizard = Wizard::make(); expect($wizard->isSkippable())->toBeFalse(); $wizard->skippable(); expect($wizard->isSkippable())->toBeTrue(); }); it('can set `startOnStep()`', function (): void { $wizard = Wizard::make(); expect($wizard->getStartStep())->toBe(1); $wizard->startOnStep(3); expect($wizard->getStartStep())->toBe(3); }); it('can set `persistStepInQueryString()`', function (): void { $wizard = Wizard::make(); expect($wizard->isStepPersistedInQueryString())->toBeFalse(); $wizard->persistStepInQueryString(); expect($wizard->isStepPersistedInQueryString())->toBeTrue(); expect($wizard->getStepQueryStringKey())->toBe('step'); }); it('can set custom key for `persistStepInQueryString()`', function (): void { $wizard = Wizard::make() ->persistStepInQueryString('wizardStep'); expect($wizard->getStepQueryStringKey())->toBe('wizardStep'); }); it('can set `cancelAction()`', function (): void { $wizard = Wizard::make(); expect($wizard->getCancelAction())->toBeNull(); $wizard->cancelAction(''); expect($wizard->getCancelAction())->toBe(''); }); it('can set `submitAction()`', function (): void { $wizard = Wizard::make(); expect($wizard->getSubmitAction())->toBeNull(); $wizard->submitAction(''); expect($wizard->getSubmitAction())->toBe(''); }); it('can set `alpineSubmitHandler()`', function (): void { $wizard = Wizard::make(); expect($wizard->getAlpineSubmitHandler())->toBeNull(); $wizard->alpineSubmitHandler('submitForm()'); expect($wizard->getAlpineSubmitHandler())->toBe('submitForm()'); }); it('can set `hiddenHeader()`', function (): void { $wizard = Wizard::make(); expect($wizard->isHeaderHidden())->toBeFalse(); $wizard->hiddenHeader(); expect($wizard->isHeaderHidden())->toBeTrue(); }); it('returns `next` for `getNextActionName()`', function (): void { $wizard = Wizard::make(); expect($wizard->getNextActionName())->toBe('next'); }); it('returns `previous` for `getPreviousActionName()`', function (): void { $wizard = Wizard::make(); expect($wizard->getPreviousActionName())->toBe('previous'); }); it('calculates `getCurrentStepIndex()` from `startOnStep()`', function (): void { $wizard = Wizard::make() ->startOnStep(2); expect($wizard->getCurrentStepIndex())->toBe(1); }); it('can modify `nextAction()` using callback', function (): void { $wizard = Wizard::make() ->nextAction(static fn ($action) => $action->label('Continue')); $nextAction = $wizard->getNextAction(); expect($nextAction->getLabel())->toBe('Continue'); }); it('can modify `previousAction()` using callback', function (): void { $wizard = Wizard::make() ->previousAction(static fn ($action) => $action->label('Go Back')); $previousAction = $wizard->getPreviousAction(); expect($previousAction->getLabel())->toBe('Go Back'); }); it('can set `skippable()` with a `Closure`', function (): void { $wizard = Wizard::make() ->skippable(static fn (): bool => true); expect($wizard->isSkippable())->toBeTrue(); }); it('can set `startOnStep()` with a `Closure`', function (): void { $wizard = Wizard::make() ->startOnStep(static fn (): int => 4); expect($wizard->getStartStep())->toBe(4); }); it('can set `hiddenHeader()` with a `Closure`', function (): void { $wizard = Wizard::make() ->hiddenHeader(static fn (): bool => true); expect($wizard->isHeaderHidden())->toBeTrue(); }); it('can set `alpineSubmitHandler()` with a `Closure`', function (): void { $wizard = Wizard::make() ->alpineSubmitHandler(static fn (): string => 'dynamicHandler()'); expect($wizard->getAlpineSubmitHandler())->toBe('dynamicHandler()'); }); it('can clear `persistStepInQueryString()` with `null`', function (): void { $wizard = Wizard::make() ->persistStepInQueryString() ->persistStepInQueryString(null); expect($wizard->isStepPersistedInQueryString())->toBeFalse(); expect($wizard->getStepQueryStringKey())->toBeNull(); }); it('returns fluent `$this` from `steps()`', function (): void { $wizard = Wizard::make(); $result = $wizard->steps([]); expect($result)->toBe($wizard); }); it('defaults `getCurrentStepIndex()` to `0`', function (): void { $wizard = Wizard::make(); expect($wizard->getCurrentStepIndex())->toBe(0); }); it('returns default label from `getNextAction()` without modifier', function (): void { $wizard = Wizard::make(); $action = $wizard->getNextAction(); expect($action->getLabel())->toBeString(); expect($action->getLabel())->not->toBeEmpty(); }); it('can clear `nextAction()` modifier with `null`', function (): void { $wizard = Wizard::make() ->nextAction(static fn ($action) => $action->label('Custom')) ->nextAction(null); $action = $wizard->getNextAction(); // After clearing, should return default label, not 'Custom' expect($action->getLabel())->not->toBe('Custom'); }); it('can set `persistStepInQueryString()` with a `Closure`', function (): void { $wizard = Wizard::make() ->persistStepInQueryString(static fn (): string => 'dynamicKey'); expect($wizard->getStepQueryStringKey())->toBe('dynamicKey'); expect($wizard->isStepPersistedInQueryString())->toBeTrue(); }); it('can set `cancelAction()` with an `Htmlable`', function (): void { $htmlable = new HtmlString(''); $wizard = Wizard::make()->cancelAction($htmlable); expect($wizard->getCancelAction())->toBe($htmlable); }); it('can clear `cancelAction()` with `null`', function (): void { $wizard = Wizard::make() ->cancelAction('') ->cancelAction(null); expect($wizard->getCancelAction())->toBeNull(); }); it('can set `submitAction()` with an `Htmlable`', function (): void { $htmlable = new HtmlString(''); $wizard = Wizard::make()->submitAction($htmlable); expect($wizard->getSubmitAction())->toBe($htmlable); }); it('can clear `submitAction()` with `null`', function (): void { $wizard = Wizard::make() ->submitAction('') ->submitAction(null); expect($wizard->getSubmitAction())->toBeNull(); }); it('can clear `previousAction()` modifier with `null`', function (): void { $wizard = Wizard::make() ->previousAction(static fn ($action) => $action->label('Custom')) ->previousAction(null); $action = $wizard->getPreviousAction(); expect($action->getLabel())->not->toBe('Custom'); }); it('can clear `alpineSubmitHandler()` with `null`', function (): void { $wizard = Wizard::make() ->alpineSubmitHandler('handler()') ->alpineSubmitHandler(null); expect($wizard->getAlpineSubmitHandler())->toBeNull(); }); describe('label', function (): void { it('returns `null` for `getLabel()` by default', function (): void { $wizard = Wizard::make(); expect($wizard->getLabel())->toBeNull(); }); it('can set `label()`', function (): void { $wizard = Wizard::make()->label('Registration'); expect($wizard->getLabel())->toBe('Registration'); }); it('can set `label()` with a `Closure`', function (): void { $wizard = Wizard::make() ->label(static fn (): string => 'Dynamic Label'); expect($wizard->getLabel())->toBe('Dynamic Label'); }); it('can set `label()` with an `Htmlable`', function (): void { $htmlable = new HtmlString('Bold'); $wizard = Wizard::make()->label($htmlable); expect($wizard->getLabel())->toBe($htmlable); }); it('reports `hasCustomLabel()` as `false` by default', function (): void { $wizard = Wizard::make(); expect($wizard->hasCustomLabel())->toBeFalse(); }); it('reports `hasCustomLabel()` as `true` after `label()` is set', function (): void { $wizard = Wizard::make()->label('Custom'); expect($wizard->hasCustomLabel())->toBeTrue(); }); it('defaults `isLabelHidden()` to `false`', function (): void { $wizard = Wizard::make(); expect($wizard->isLabelHidden())->toBeFalse(); }); it('can set `hiddenLabel()`', function (): void { $wizard = Wizard::make()->hiddenLabel(); expect($wizard->isLabelHidden())->toBeTrue(); }); it('can set `hiddenLabel()` with a `Closure`', function (): void { $wizard = Wizard::make() ->hiddenLabel(static fn (): bool => true); expect($wizard->isLabelHidden())->toBeTrue(); }); it('can translate label with `translateLabel()`', function (): void { $wizard = Wizard::make() ->label('validation.required') ->translateLabel(); expect($wizard->getLabel())->toBe(__('validation.required')); }); }); describe('containment', function (): void { it('defaults `isContained()` to `true`', function (): void { $wizard = Wizard::make(); expect($wizard->isContained())->toBeTrue(); }); it('can set `contained()` to `false`', function (): void { $wizard = Wizard::make()->contained(false); expect($wizard->isContained())->toBeFalse(); }); it('can set `contained()` with a `Closure`', function (): void { $wizard = Wizard::make() ->contained(static fn (): bool => false); expect($wizard->isContained())->toBeFalse(); }); }); describe('extra Alpine attributes', function (): void { it('returns empty array for `getExtraAlpineAttributes()` by default', function (): void { $wizard = Wizard::make(); expect($wizard->getExtraAlpineAttributes())->toBe([]); }); it('can set `extraAlpineAttributes()`', function (): void { $wizard = Wizard::make() ->extraAlpineAttributes(['x-on:click' => 'open = true']); expect($wizard->getExtraAlpineAttributes())->toBe(['x-on:click' => 'open = true']); }); it('can merge `extraAlpineAttributes()`', function (): void { $wizard = Wizard::make() ->extraAlpineAttributes(['x-on:click' => 'open = true']) ->extraAlpineAttributes(['x-bind:class' => 'active'], merge: true); $attributes = $wizard->getExtraAlpineAttributes(); expect($attributes)->toHaveKey('x-on:click', 'open = true'); expect($attributes)->toHaveKey('x-bind:class', 'active'); }); it('can set `extraAlpineAttributes()` with a `Closure`', function (): void { $wizard = Wizard::make() ->extraAlpineAttributes(static fn (): array => ['x-data' => '{}']); expect($wizard->getExtraAlpineAttributes())->toBe(['x-data' => '{}']); }); }); describe('rendering', function (): void { it('can render', function (): void { livewire(RenderWizard::class)->assertSuccessful(); }); it('can render with `skippable()`', function (): void { livewire(RenderWizardWithSkippable::class)->assertSuccessful(); }); it('can render with `skippable()` set via `Closure`', function (): void { livewire(RenderWizardWithClosureSkippable::class)->assertSuccessful(); }); it('can render with `hiddenHeader()`', function (): void { livewire(RenderWizardWithHiddenHeader::class)->assertSuccessful(); }); it('can render with `hiddenHeader()` set via `Closure`', function (): void { livewire(RenderWizardWithClosureHiddenHeader::class)->assertSuccessful(); }); it('can render with `contained(false)`', function (): void { livewire(RenderWizardWithContainedFalse::class)->assertSuccessful(); }); it('can render with `persistStepInQueryString()`', function (): void { livewire(RenderWizardWithPersistStep::class)->assertSuccessful(); }); it('can render with label', function (): void { livewire(RenderWizardWithLabel::class)->assertSuccessful(); }); it('can render with `cancelAction()`', function (): void { livewire(RenderWizardWithCancelAction::class)->assertSuccessful(); }); it('can render with `submitAction()`', function (): void { livewire(RenderWizardWithSubmitAction::class)->assertSuccessful(); }); it('preserves a custom `livewireTarget()` on the next action', function (): void { livewire(RenderWizardWithCustomNextActionLivewireTarget::class) ->assertSeeHtml('wire:target="customTarget"'); }); }); it('can render `Wizard` in the browser', function (): void { $this->actingAs(User::factory()->create()); visit('/wizard-browser-test') ->assertNoSmoke() ->assertNoAccessibilityIssues(); visit('/wizard-browser-test') ->inDarkMode() ->assertNoAccessibilityIssues(); }); it('only shows the next action loading indicator for its own request', function (): void { $this->actingAs(User::factory()->create()); $nextAction = '[data-testid="wizard-next-action"]'; $nextActionLoadingIndicator = "{$nextAction} .fi-loading-indicator"; $browser = visit('/wizard-browser-test') ->click('[data-testid="wizard-dynamic-select"] .fi-select-input-btn') ->assertAttribute(":has(> {$nextAction})", 'aria-disabled', 'true') ->assertMissing($nextActionLoadingIndicator) ->click('Draft') ->assertAttributeMissing(":has(> {$nextAction})", 'aria-disabled'); $browser->page()->locator($nextAction)->click(); $browser ->assertVisible($nextActionLoadingIndicator) ->assertVisible('#profile-contact') ->assertNoSmoke(); }); it('keeps the next action focused and blocked during other requests', function (): void { $this->actingAs(User::factory()->create()); $nextAction = '[data-testid="wizard-next-action"]'; foreach ([false, true] as $isDarkMode) { $browser = visit('/wizard-browser-test'); if ($isDarkMode) { $browser = $browser->inDarkMode(); } $nextActionLocator = $browser->page()->locator($nextAction); $browser ->click('[data-testid="wizard-dynamic-select"] .fi-select-input-btn') ->assertAttribute(":has(> {$nextAction})", 'aria-disabled', 'true'); $nextActionLocator->press('Enter'); $nextActionLocator->press('Space'); $browser ->assertPresent("{$nextAction}:focus") ->assertScript('window.wizardNextActionActivationCount ?? 0', 0) ->assertAttributeMissing(":has(> {$nextAction})", 'aria-disabled') ->assertVisible('#profile-details') ->assertNoSmoke() ->assertNoAccessibilityIssues(); $nextActionLocator->click(); $browser ->assertAttribute($nextAction, 'aria-disabled', 'true') ->assertPresent("{$nextAction}:focus"); $nextActionLocator->press('Enter'); $nextActionLocator->press('Space'); $browser ->assertScript('window.wizardNextActionActivationCount', 1) ->assertVisible('#profile-contact') ->assertAttributeMissing(":has(> {$nextAction})", 'aria-disabled') ->assertNoSmoke(); } }); class WizardTransitions extends Livewire { public array $hooks = []; public bool $hideFirst = false; public bool $skippable = false; public string $failure = ''; public string $restriction = ''; public int $startStep = 1; public string $mutation = ''; public bool $changed = false; public function form(Schema $schema): Schema { return $schema->statePath('data')->components([ Wizard::make(fn (): array => [ Step::make('Details')->key('details') ->hidden(fn (): bool => $this->hideFirst || ($this->changed && ($this->mutation === 'hide-current'))) ->beforeValidation(function (): void { $this->hooks[] = 'details:before'; if ($this->failure === 'before') { throw new Halt; } }) ->afterValidation(function (): void { $this->hooks[] = 'details:after'; $this->changed = true; if ($this->failure === 'after') { throw new Halt; } }) ->schema([ TextInput::make('name')->required()->default(fn (): ?string => $this->failure === 'validation' ? null : 'Ada'), ]), Step::make('Contact')->key(($this->changed && ($this->mutation === 'replace-next')) ? 'replacement' : 'contact') ->hidden(fn (): bool => $this->changed && ($this->mutation === 'hide-next')) ->beforeValidation(function (): void { $this->hooks[] = 'contact:before'; if ($this->failure === 'contact-before') { throw new Halt; } }) ->afterValidation(function (): void { $this->hooks[] = 'contact:after'; if ($this->failure === 'contact-after') { throw new Halt; } }) ->schema([ TextInput::make(($this->changed && ($this->mutation === 'replace-next')) ? 'replacement_notes' : 'contact_notes') ->required(fn (): bool => $this->failure === 'contact-validation'), ]), Step::make('Review')->key('review') ->beforeValidation(function (): void { $this->hooks[] = 'review:before'; }) ->schema([TextInput::make('review_notes')]), ])->key('wizard')->skippable(fn (): bool => $this->skippable) ->startOnStep(fn (): int => $this->startStep) ->nextAction(fn (Action $action): Action => $action ->disabled($this->restriction === 'disabled') ->authorize($this->restriction !== 'unauthorized')), ]); } } class RenderWizard extends Component implements HasSchemas { use InteractsWithSchemas; public function infolist(Schema $schema): Schema { return $schema->state([])->components([Wizard::make()->steps([Step::make('Step 1'), Step::make('Step 2')])]); } public function render(): string { return '