111 - Add keyboard accessibility to the shared form modal

This commit is contained in:
myrmidex 2026-08-10 23:59:55 +02:00
parent b7ec75ff65
commit 034d0f7eac
2 changed files with 112 additions and 2 deletions

View file

@ -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>

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