Table
The Gesture Layer
A wire-table table can behave like a desktop application: arrow keys walk the rows, Shift works a range, the mouse sweeps down the checkbox column, right click opens a row menu, ? explains itself, and a fill handle drags one value across many cells.
On this page
- What a table gets without asking
- What counts as a gesture
- Mixing them
- A permission is not a switch-on
- keyboard() has three states
- What the layer does not govern
- The active-row marker
- A project-wide default
- It is off on the server too
- Phones get buttons instead
- API reference
- On the table
- On TableGestures
- Recipes
- Troubleshooting
- Choosing
- See Also
A wire-table table can behave like a desktop application: arrow keys walk the
rows, Shift works a range, the mouse sweeps down the checkbox column, right
click opens a row menu, ? explains itself, and a fill handle drags one value
across many cells.
That is exactly right for a back office. It is usually wrong for a public listing, where a highlighted row and a hijacked right click are noise at best.
So it is one switch, and a table starts on the quiet side of it:
->gestures()
That is the desktop-application table. Without it you get an ordinary web table — the one most pages want.
What a table gets without asking
Every way of operating a row is off until you ask. Three capabilities change how the table answers a visitor who never intended to operate it, and all three wait:
- Keyboard navigation puts the rows in the tab order, marks an active row
and starts answering arrows and
mod+key. - The drag sweep turns a press in the checkbox column into a block selection — a gesture people find by accident before they find it on purpose.
- Range selection re-reads a modified click:
Shift+click stops being a click and becomes "everything between here and the last one". Right in a file manager, startling in a list of blog posts.
A selectable table therefore starts as checkboxes and nothing more, and the
delegated Alpine controller is not even rendered. What stays allowed needs an
invitation of its own anyway: a context menu needs actions bound to it, the fill
handle needs ->fillHandle(), and the ? help needs the keyboard layer this
default leaves off.
// An ordinary listing. Checkboxes work and nothing else does:// no arrow keys, no drag selecting, no modified click meaning something else.Table::make()->selectable() // The same table as an application.Table::make()->gestures()->selectable()
To go further the other way — no right-click menu, no ranges, no fill handle, nothing at all — say so:
->gestures(false)
What counts as a gesture
Six capabilities, each switchable on its own. "Default" is what a table that
never calls gestures() gets:
| Capability | Default | What it covers |
|---|---|---|
keyboard |
off | Grid navigation: roving tabindex, arrows, Home/End, PageUp/PageDown, Enter / Shift+Enter for the primary and secondary record action, Space to toggle the selection, and every action's own keyboardShortcut() / onKey() against the active row. Also what makes the table an ARIA grid. |
rangeSelection |
off | Shift+click, mod+click and mod+Shift+click on a row, plus Shift+arrow, Shift+Home and Shift+End from the keyboard. |
dragSelect |
off | The mouse sweep: press in the checkbox column and drag to select a block of rows. |
contextMenu |
on | The right-click row menu — both rowContextMenu() and any onContextMenu() record action. |
shortcutHelp |
on¹ | The ? shortcut help. |
fillHandle |
on² | The Excel-style fill handle on editable cells. |
mod is Ctrl on Windows and ⌘ on macOS.
¹ Allowed, but it reads the keyboard layer, so with the default it never opens.
² Allowed, but the table still has to call ->fillHandle().
With the default, then, the only gestures a table really offers are the ones it declared itself: a right-click menu if an action is bound to one, and the fill handle if it asked for one.
Mixing them
Pass a closure. It receives this table's gestures and configures them in place — the return value is ignored, so a fluent chain and a multi-line body both work.
->gestures(fn (TableGestures $g) => $g ->keyboard() // arrows, Enter, shortcuts … ->dragSelect(false)) // … but still no mouse sweep
Every setter takes a bool, so ->contextMenu(false) reads as well as
->contextMenu().
You can also hand over a prepared set, which is useful when several tables share one house style:
use NyonCode\WireTable\Support\TableGestures; $readOnly = TableGestures::none()->contextMenu(); // …then, in each table:->gestures($readOnly)
TableGestures::defaults(), TableGestures::all() and TableGestures::none()
are the three starting points: the shipped default, everything, nothing.
A permission is not a switch-on
Every capability is a permission, never a trigger. Turning one on does not conjure the thing it governs:
dragSelectandrangeSelectionstill need->selectable()(or->bulkActions(), which implies it) — there has to be a selection for a range to grow in. Both are also off in the default, so they need the permission and the selection.fillHandlestill needs->fillHandle()on the table and editable columns.shortcutHelpstill needs the keyboard layer, because the keyboard layer is what listens for the key.
So ->gestures(fn ($g) => $g->dragSelect()) on a table without selectable()
changes nothing. This is deliberate: the gesture layer decides what a table is
allowed to do, and the rest of the table API decides what it has.
keyboard() has three states
The other five capabilities are plain booleans. keyboard is three-state,
because "on" has to mean two different things:
| Value | Meaning |
|---|---|
false (the default) |
Off |
null |
The table decides — on for a table with record actions or a selectable one. This is what gestures() sets |
true |
Force it on, even for a table with neither |
gestures() leaves the keyboard at null rather than forcing it, because a
table with no record actions and no selection has nothing for the arrows to do,
and a roving tabindex over inert rows is worse than none:
Table::make()->gestures() // not a gridTable::make()->gestures()->selectable() // a gridTable::make()->gestures(fn (TableGestures $g) => $g->keyboard(true)) // a grid regardless
What the layer does not govern
An explicitly declared record action keeps firing. A binding like
->recordAction(RecordAction::make(Action::make('view'))->onClick())
is a deliberate statement about this table, not an implicit affordance the table
turned on for itself — so it survives gestures(false). The gesture layer only
governs the layer a table would otherwise switch on for itself.
The one exception is ->onKey(), which needs a keyboard layer to listen with.
With keyboard off, an onKey() binding has nowhere to fire from.
Selection itself is likewise untouched. With every gesture off, the checkboxes, both select-all controls and the bulk bar work exactly as they always did — you lose the shortcuts to them, not the feature. The selection cell then answers a modified click by toggling, since with ranges off nothing else would.
The active-row marker
Rows carry the active-row marker when any gesture needs somewhere to grow from — that is, when the table uses grid semantics, range selection, or the sweep.
A table left with nothing but a declared click action marks nothing. A click there opens the record and moves on; a highlighted row left behind would be an application affordance on a page that asked for none.
A project-wide default
Set it once for every table:
// config/wire-table.php'defaults' => [ 'gestures' => true,],
null (or a missing key) keeps the shipped default described above, true
allows everything for every table — a back office turns the layer on once here
instead of on every table — false allows nothing, and a map mixes:
'gestures' => ['keyboard' => true, 'drag_select' => false],
Capability keys are matched loosely — drag_select, drag-select, dragSelect
and dragselect are the same key. An unknown key throws
TableConfigurationException rather than doing nothing quietly, because a typo
in a permission is the kind of mistake that only shows up as "why doesn't this
work" six months later.
A per-table ->gestures(...) always wins over the config default.
It is off on the server too
Switching a capability off is not a matter of the client ignoring events. The markup and the endpoints go with it:
- The delegated Alpine controllers are not rendered. A table with nothing but the gestures off renders no controller at all, and its asset bundles are not requested.
- The table stops being an ARIA
grid: norole="grid", norole="row", no rovingtabindex. - The rows are not focusable, so nothing steals focus on click.
- The fill endpoint refuses.
fillHandleoff closesfillTableCellsserver-side, not just the handle in the UI. - The shortcut legend drops the rows it no longer applies to — with ranges off,
Shift+arrow is not listed in the?help, because it does not work.
That last point is the general rule: the legend is generated from what the table actually does, so it can never drift from reality.
Phones get buttons instead
A gesture-driven table is a desktop idea. There is no double click on a phone, no right click, and no hover to discover either of them — so a record action that is behaviour-only on the desktop would be unreachable on a stacked mobile card.
It is therefore rendered as an ordinary button there, and only there:
->recordAction(RecordAction::make(Action::make('open'))->onDoubleClick())
| Surface | What the user gets |
|---|---|
| Desktop | A double-click gesture. No column, no button. |
| Mobile card | An Open button. |
The fallback is careful about not doubling anything:
- Actions already in
->actions()keep their order, and the record actions are appended after them. recordAction('edit'), which only references an action already declared in->actions(), shows one button — not the same one twice.- An action promoted into the column with
->alsoInRowActions()is already a button, so it is left alone. - The fallback buttons count towards
->collapseActionsOnMobile(), so a card does not quietly grow past the threshold you set.
Switch it off when a card is meant to stay clean:
->recordActionButtonsOnMobile(false)
API reference
Everything the layer exposes, in one place.
On the table
| Call | What it does |
|---|---|
->gestures() |
Allow every capability. The keyboard is left at "the table decides" |
->gestures(false) |
Allow nothing at all |
->gestures(fn (TableGestures $g) => …) |
Configure this table's capabilities in place |
->gestures(TableGestures $set) |
Adopt a prepared set |
->recordActionButtonsOnMobile(bool) |
Whether behaviour-only record actions render as buttons on a stacked card (default true) |
Readers, useful in a custom view or a test:
| Call | Answers |
|---|---|
getGestures(): TableGestures |
The raw permissions, before any prerequisite |
usesGridSemantics(): bool |
Is this an ARIA grid? The single owner of that decision |
keyboardNavEnabled(): bool |
Alias of the above, read from the view |
usesRangeSelection(): bool |
Do Shift/mod clicks and Shift+arrows work a range? |
usesDragSelect(): bool |
Does a drag down the checkbox column sweep? |
usesShortcutHelp(): bool |
Does ? open the legend? |
usesActiveRowMarker(): bool |
Do rows carry the active-row marker? |
mountsRecordActionController(): bool |
Is the delegated Alpine controller rendered at all? |
getGestureConfig(): array |
['sweep' => bool, 'ranges' => bool] — what the client controller consumes |
getTableRole(): ?string |
'grid' or null |
hasRowContextMenu(): bool |
Is there a right-click menu (permission included)? |
isFillHandleEnabled(): bool |
Is the fill handle offered (permission included)? |
On TableGestures
use NyonCode\WireTable\Support\TableGestures;
| Call | Meaning |
|---|---|
TableGestures::defaults() |
The shipped default: keyboard and drag sweep off, the rest allowed |
TableGestures::all() |
Everything allowed; keyboard left at null |
TableGestures::none() |
Nothing allowed |
TableGestures::fromConfig($value) |
Build from a config value (null / bool / map) |
->keyboard(?bool), ->rangeSelection(bool), ->dragSelect(bool), ->contextMenu(bool), ->shortcutHelp(bool), ->fillHandle(bool) |
The setters; each returns $this |
->allowsKeyboard(): ?bool and ->allows*(): bool |
The permission, before the table's own prerequisites |
->toArray(): array |
All six as data |
A permission and an outcome are different questions: allowsDragSelect() says
the table is allowed to sweep, usesDragSelect() says it actually does (which
also needs selectable()).
Recipes
A public listing. Nothing to do:
$table->model(Post::class)->columns([...]);
A back-office grid. One call, or 'gestures' => true in config for the whole
project:
$table->gestures()->selectable()->bulkActions([DeleteBulkAction::make()]);
Click a row to open it, on an otherwise quiet table. A declared record action
is outside the layer, so this needs no gestures() at all:
$table->recordAction(RecordAction::make(Action::make('view'))->onClick());
Keyboard yes, sweeping no. For long lists where an accidental drag would select a hundred rows:
$table->gestures(fn (TableGestures $g) => $g->keyboard())->selectable();
A house style across many tables. Build the set once and hand it over:
// app/Tables/Gestures.phppublic static function backOffice(): TableGestures{ return TableGestures::all()->dragSelect(false);} // in each table$table->gestures(Gestures::backOffice());
A table inside a page with its own keyboard handling. Keep the mouse half:
$table->gestures(fn (TableGestures $g) => $g->rangeSelection()->dragSelect())->selectable();
Ranges but no sweep. Shift+click for a block, without a drag that selects
by accident:
$table->gestures(fn (TableGestures $g) => $g->rangeSelection())->selectable();
Troubleshooting
| Symptom | Why | Fix |
|---|---|---|
| Arrow keys do nothing, rows are not focusable | The keyboard layer is opt-in | ->gestures() |
->gestures() is there and it still is not a grid |
Nothing for the arrows to drive — no record actions, no selection | Add ->selectable(), or force it with ->gestures(fn ($g) => $g->keyboard(true)) |
? opens nothing |
It reads the keyboard layer, and an empty legend renders no modal at all | Turn the keyboard on; check shortcutLegend()->isEmpty() |
| A drag down the checkbox column selects nothing | dragSelect is off by default |
->gestures(), or ->gestures(fn ($g) => $g->dragSelect()) |
Shift+click toggles one row instead of a range |
rangeSelection is off by default |
->gestures(), or ->gestures(fn ($g) => $g->rangeSelection()) |
| Right-click shows the browser menu | No action is bound to it, or contextMenu is off |
Bind one with ->onContextMenu(); check hasRowContextMenu() |
| The fill handle does not appear | It needs ->fillHandle() and the permission and a fillable editable column |
Check isFillHandleEnabled() and Column::fillable() |
An ->onKey() binding never fires |
Keys are read by the keyboard layer | ->gestures() |
| A record action is unreachable on a phone | The fallback was switched off | ->recordActionButtonsOnMobile() |
Choosing
| Table | Suggested |
|---|---|
| Public listing, marketing page | Nothing to do — the default |
| Back-office list, keyboard-heavy operators | ->gestures(), or 'gestures' => true in config |
| Public page that must not even right-click | ->gestures(false) |
| Read-only report, right click still useful | TableGestures::none()->contextMenu() |
| Long list, keyboard useful, sweeping risky | ->gestures(fn ($g) => $g->keyboard()) |
| Embedded in a page with its own keyboard handling | ->gestures(fn ($g) => $g->rangeSelection()->dragSelect()) |
| Selection matters, a stray drag does not | ->gestures(fn ($g) => $g->rangeSelection()) |
See Also
- Selecting Rows — what each selection gesture does
- Record Actions — binding an action to a row gesture
- Advanced — the fill handle