TL;DR
- Shipped two a11y wins in laravel-livewire-tables v4.1.0: focus traps on popovers and a keyboard alternative to drag-reorder.
- Focus trap = Alpine's
x-trap. One directive gives you focus-in, Tab-wrap, and ESC-to-close-and-return. - Drag handles are now focusable buttons that move rows with ArrowUp/ArrowDown — mouse is no longer mandatory.
- Bonus: an opt-in client-side column toggle that flips visibility via
x-showwith zero Livewire round-trips.
Drag-and-drop and mouse-only popovers are the two spots where datatables quietly lock out keyboard and screen-reader users. Both got fixed today. Here's the how and the why.
Focus traps without writing a focus manager
When a filter or column dropdown opens, focus should move into it, Tab should cycle within it, and ESC should close it and hand focus back to the button that opened it. Writing that by hand means tracking the first/last focusable element, listening for Tab at the boundaries, and stashing the trigger to restore later. It's fiddly and easy to get subtly wrong.
Alpine's x-trap does the whole dance in one directive:
<div x-trap="filterPopoverOpen" ...>
{{-- popover contents --}}
</div>
Bind it to the same boolean that controls the popover's visibility. When the expression flips true, focus moves in; ESC and Tab-wrapping come for free; when it flips false, focus returns to the trigger. Same pattern on the column-select and bulk-actions popovers — three popovers, one mental model.
TL;DR of this section: don't hand-roll focus management. x-trap is a battle-tested primitive; wiring it to your existing open/close state is a one-line change per popover.
Keyboard row reorder
Drag-to-reorder is the classic "works great with a mouse, impossible without one" feature. The fix isn't to replace drag — it's to give the drag handle a keyboard mode too. While reordering is active, the handle becomes a real button and listens for arrow keys:
<x-livewire-tables::table.td.plain
x-show="currentlyReorderingStatus"
role="button"
aria-label="{{ __('Reorder') }}"
x-bind:tabindex="currentlyReorderingStatus ? 0 : -1"
x-on:keydown.arrow-up.prevent.stop="moveRow(event, -1)"
x-on:keydown.arrow-down.prevent.stop="moveRow(event, 1)">
{{-- handle icon --}}
</x-livewire-tables::table.td.plain>
Three things make it correct, not just present:
| Detail | Why it matters |
|---|---|
role="button" + aria-label
|
The icon-only handle now announces itself as an actionable control with a name. |
tabindex bound to reorder state |
The handle is only in the tab order while reordering — no dead stops otherwise. |
.prevent.stop on arrow keys |
Stops the page from scrolling and the event from bubbling when a row moves. |
Same moveRow() logic backs both the drag and the keyboard path, so behaviour stays consistent.
Prove it with a test
Interactive focus behaviour ultimately needs a real browser (and a screen-reader pass), but the markup contract is cheap to lock down in Pest so a refactor can't silently drop the hooks:
it('renders keyboard-operable reorder handles while reordering', function () {
livewire(PetsTable::class)
->call('enableReordering')
->assertSeeHtml('role="button"')
->assertSeeHtml('aria-label="Reorder"')
->assertSeeHtml('moveRow(event, -1)')
->assertSeeHtml('moveRow(event, 1)');
});
it('traps focus inside the filter popover (x-trap)', function () {
livewire(PetsTable::class)
->assertSeeHtml('x-trap="filterPopoverOpen"');
});
These aren't a substitute for testing actual focus movement — they're a guard rail. If someone rewrites the reorder cell and forgets the ARIA hooks, the suite goes red before review.
Bonus: client-side column toggle
Same release, different itch. Toggling a column normally costs a Livewire round-trip. There's now an opt-in mode — setUseClientSideColumnVisibilityEnabled() — where every selectable column renders up front and the dropdown flips it via Alpine x-show, entangled with selectedColumns. Toggling feels instant; the selection still syncs and persists on the next Livewire request. It's opt-in on purpose (rendering every column has a cost) and it steps aside on the Flux theme, whose native cells carry no Alpine hooks.
Takeaway
Accessibility on a complex widget isn't one big lift — it's a handful of small, correct primitives: x-trap for focus, role/aria-label/tabindex for keyboard controls, and a markup-contract test so none of it rots. Reach for the framework's primitive before you write your own focus manager.
Top comments (0)