Release v1.4.0 #146
2 changed files with 112 additions and 2 deletions
|
|
@ -14,14 +14,45 @@
|
||||||
][$maxWidth];
|
][$maxWidth];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
<div
|
||||||
|
x-data="{
|
||||||
|
trigger: null,
|
||||||
|
focusables() {
|
||||||
|
// offsetParent is null for position:fixed too; no modal slot uses it.
|
||||||
|
return [...$el.querySelectorAll('a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])')]
|
||||||
|
.filter(el => ! el.hasAttribute('disabled') && el.offsetParent !== null)
|
||||||
|
},
|
||||||
|
firstFocusable() { return this.focusables()[0] },
|
||||||
|
lastFocusable() { return this.focusables().slice(-1)[0] },
|
||||||
|
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
|
||||||
|
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
|
||||||
|
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
|
||||||
|
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) - 1 },
|
||||||
|
init() {
|
||||||
|
this.trigger = document.activeElement;
|
||||||
|
document.body.classList.add('overflow-y-hidden');
|
||||||
|
this.$nextTick(() => this.firstFocusable()?.focus());
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
document.body.classList.remove('overflow-y-hidden');
|
||||||
|
this.trigger?.focus();
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
x-on:keydown.escape.window="$wire.{{ $close }}()"
|
||||||
|
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable()?.focus()"
|
||||||
|
x-on:keydown.shift.tab.prevent="prevFocusable()?.focus()"
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
>
|
||||||
<div class="flex min-h-full items-center justify-center p-4">
|
<div class="flex min-h-full items-center justify-center p-4">
|
||||||
<div class="fixed inset-0 bg-gray-900/50 backdrop-blur-xs transition-opacity" wire:click="{{ $close }}"></div>
|
<div class="fixed inset-0 bg-gray-900/50 backdrop-blur-xs transition-opacity" wire:click="{{ $close }}"></div>
|
||||||
|
|
||||||
<div class="relative z-10 w-full {{ $maxWidthClass }} max-h-[90vh] overflow-y-auto rounded-lg bg-white dark:bg-gray-800 p-6 text-left shadow-xl">
|
<div class="relative z-10 w-full {{ $maxWidthClass }} max-h-[90vh] overflow-y-auto rounded-lg bg-white dark:bg-gray-800 p-6 text-left shadow-xl">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 id="modal-title" class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $title }}</h3>
|
<h3 id="modal-title" class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $title }}</h3>
|
||||||
<button type="button" wire:click="{{ $close }}" class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
|
<button type="button" wire:click="{{ $close }}" class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300" aria-label="Close dialog">
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
79
tests/Feature/Livewire/FormModalAccessibilityTest.php
Normal file
79
tests/Feature/Livewire/FormModalAccessibilityTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Livewire;
|
||||||
|
|
||||||
|
use App\Livewire\Feeds;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class FormModalAccessibilityTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function openModalHtml(): string
|
||||||
|
{
|
||||||
|
return Livewire::test(Feeds::class)
|
||||||
|
->call('openCreateModal')
|
||||||
|
->html();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_escape_calls_the_livewire_close_method(): void
|
||||||
|
{
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'x-on:keydown.escape.window="$wire.closeCreateModal()"',
|
||||||
|
$this->openModalHtml()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tab_and_shift_tab_are_trapped(): void
|
||||||
|
{
|
||||||
|
$html = $this->openModalHtml();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('x-on:keydown.tab.prevent', $html);
|
||||||
|
$this->assertStringContainsString('x-on:keydown.shift.tab.prevent', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_init_locks_scroll_and_focuses_the_first_element(): void
|
||||||
|
{
|
||||||
|
$html = $this->openModalHtml();
|
||||||
|
|
||||||
|
$this->assertStringContainsString("document.body.classList.add('overflow-y-hidden')", $html);
|
||||||
|
$this->assertStringContainsString('firstFocusable()?.focus()', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_destroy_restores_scroll_and_focus(): void
|
||||||
|
{
|
||||||
|
$html = $this->openModalHtml();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('destroy()', $html);
|
||||||
|
$this->assertStringContainsString("document.body.classList.remove('overflow-y-hidden')", $html);
|
||||||
|
$this->assertStringContainsString('this.trigger?.focus()', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_teardown_uses_a_data_method_not_a_directive(): void
|
||||||
|
{
|
||||||
|
$this->assertStringNotContainsString('x-destroy', $this->openModalHtml());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dialog_keeps_its_aria_attributes(): void
|
||||||
|
{
|
||||||
|
$html = $this->openModalHtml();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('role="dialog"', $html);
|
||||||
|
$this->assertStringContainsString('aria-modal="true"', $html);
|
||||||
|
$this->assertStringContainsString('aria-labelledby="modal-title"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_close_button_is_labelled(): void
|
||||||
|
{
|
||||||
|
$this->assertStringContainsString('aria-label="Close dialog"', $this->openModalHtml());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_modal_is_absent_until_opened(): void
|
||||||
|
{
|
||||||
|
$html = Livewire::test(Feeds::class)->html();
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('role="dialog"', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue