111 - Add keyboard accessibility to the shared form modal
This commit is contained in:
parent
b7ec75ff65
commit
034d0f7eac
2 changed files with 112 additions and 2 deletions
|
|
@ -14,14 +14,45 @@
|
|||
][$maxWidth];
|
||||
@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="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="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>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</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