Skip to content

1.14.0 — the gesture layer, record actions, and the keyboard - #63

Merged
ONyklicek merged 75 commits into
1.xfrom
1.14.0
Jul 28, 2026
Merged

1.14.0 — the gesture layer, record actions, and the keyboard#63
ONyklicek merged 75 commits into
1.xfrom
1.14.0

Conversation

@ONyklicek

Copy link
Copy Markdown
Owner

Release branch for 1.14.0 — 388 commits. Full notes in CHANGELOG.md; the headlines:

Row interaction, and the switch that governs it

  • Table::gestures() — the desktop gesture layer, opt-in. Keyboard grid navigation, range selection and the drag sweep down the checkbox column are off until a table asks. Each of them changes how the table answers a visitor who never meant to operate it: the rows enter the tab order, an active row is marked, a drag starts selecting, a Shift+click stops meaning a click. A selectable table therefore starts as checkboxes and nothing more, and does not even mount the delegated Alpine controller.
    • ->gestures() promotes a table to an application; ->gestures(fn (TableGestures $g) => $g->keyboard()->dragSelect(false)) mixes the six capabilities (keyboard, rangeSelection, dragSelect, contextMenu, shortcutHelp, fillHandle); ->gestures(false) leaves nothing at all.
    • Project-wide default in config('wire-table.defaults.gestures'); an unknown capability throws rather than doing nothing quietly.
    • A capability is a permission, not a trigger (allowing the sweep still needs selectable()), and an explicitly declared record action is outside the layer — a bound onClick() keeps firing with the layer off; only onKey() needs the keyboard.
    • Enforced server-side: no role="grid", no roving tabindex, no context-menu panels in the markup, fillTableCells() refused, and the ? legend lists only what the table actually answers to.
  • Record actions (whole-row click / double-click / right-click / key), resolved to the existing openActionModal pipeline — no second lifecycle — with one delegated controller per <tbody> rather than per-row Alpine islands.
  • Behaviour-only record actions reach a phone as ordinary buttons. A finger has no double-click, no right-click and no Delete key, so the stacked card renders the same action as a button, and only there. Nothing doubles, and the card's copy drops the action's keyboardShortcut() — a rendered button binds it as a window listener, so without that one Delete press ran the action once per card.
  • ARIA: a table that asks for gestures announces itself as a grid, with aria-rowcount/aria-rowindex over the whole result set and a live region for the selection.

Keyboard, modals, dates

  • Table::shortcutLegend() returns the table's own gestures as data; ? renders it as a modal, client-side.
  • openOn: on every canonical modal shell — open a modal from JavaScript with no Livewire roundtrip.
  • DateBoundary (wire-core) is the canonical owner of a date bound, so minDate()/maxDate() mean the same thing in every shape they are written in.
  • HasKeyboardShortcut::withoutKeyboardShortcut() (wire-core): a copy of an action that must not answer the key on a second surface.

Fixes worth naming

  • A modal never took the focus and never gave it back — the keyboard was unusable during and after.
  • Dragging a column header moved the checkbox column instead of the column's data.
  • Shift-clicking a checkbox selected one row where the same click on the row body selected the range.
  • One click on the header checkbox in "select all matching" mode inverted the selection.
  • The image cropper was a dialog that behaved like part of the page.

Breaking

  • ->onKey() on a key the grid navigation owns now throws at configuration time instead of silently dropping the binding. Such a binding was already dead code.

Verification

  • composer test:core / test:forms / test:table / test:sortable / test:boost — green
  • vendor/bin/pest --testsuite "Integration" — green
  • composer lint, composer analyse — clean
  • composer coverage:verify — every package at or above its floor, diff coverage complete
  • npm run docs:check, npm run docs:api — clean
  • npm run verify:drivers44/44 CDP drivers, including selection-gestures 77/77, gesture-lab 28/28, gestures-off 25/25, record-active-row 18/18, fill-handle 26/26

Docs ship in both languages (docs/ and docs/cs/), with the boost guidelines and doc mirror in sync.

ONyklicek added 30 commits July 24, 2026 19:53
… core seam

The package graph is wire-sortable → wire-table → wire-forms → wire-core, and
InteractsWithActions documents that wire-core must not depend on wire-forms —
yet six core sites imported and even constructed wire-forms' concrete Form /
FormConfig (HasModal, ActionHalt, the HasForm contract, both FormSaving/
FormSavedPayload plugin hooks), and the core actions.modal-host view called
getActionModalFormInstance(), a method only the wire-forms bridge defines. In a
split-published standalone nyoncode/wire-core this is not academic: the payloads
and contracts reference undefined classes, and opening any action modal on a
plain WithActions host fatally calls the missing bridge method.

Core now owns the seam. A narrow ModalForm interface (extends Htmlable;
statePath/livewire/fill/getInitialState/validate), a ModalFormFactory resolved
from the container via ModalForms, and a FormConfigContract for the hook
payloads. wire-forms' Form implements ModalForm (no method-body changes), a
FormModalFormFactory is bound in WireFormsServiceProvider, and FormConfig
implements the contract. When wire-forms is absent the factory is unbound and
ModalForms::make() degrades to null (the modal renders form-less) instead of
fatally naming a class core does not ship; two null form-instance seams on core
InteractsWithActions (overridden by the bridge via insteadof in WithActions /
WithTable) keep the core modal-host view honest.

The public HasModal::form() / ActionHalt::form() API is unchanged in practice —
Form implements ModalForm, so ->form(Form::make()->schema([...])), ->form([...])
and closures all still type-check. Field objects passed to ->form([...]) remain
wire-forms types, so this is compile/load-level decoupling (a standalone core
boots against only its own dependencies), not a claim that core is value-level
form-free.

Covered by ModalFormsTest (factory resolution + graceful degradation when
unbound), a form-free-host seam test in InteractsWithActionsTest, getModel()
coverage in FormConfigTest, and the existing form-behaviour suites, which build
through the factory bound in the core TestCase. Full suites green (core/forms/
table/integration); composer analyse clean.
Migrate the wire-forms editor bundle from TipTap v2 to v3:

- StarterKit v3 now bundles Link and Underline, so drop the standalone
  imports and configure Link through StarterKit.configure({ link }) to
  avoid duplicate-extension registration.
- setContent's second argument is now an options object; pass
  { emitUpdate: false } so a server-driven fill no longer echoes back
  into Livewire (a bare false would fall through to emitUpdate: true).
- @tiptap/extension-table dropped its default export in v3; switch the
  addon chunk to the named { Table } import.

Rebuild the ESM code-split dist and extend the browser driver with v3
regression checks: bold/underline/heading via StarterKit and a
duplicate-extension console-warning guard.
Clicking a row (or opening its context menu) now marks it as the active
row, so the arrow keys always continue from the row the user last touched.
The marker and the roving tabindex become Alpine bindings (rowClass /
rowTabindex) so they survive the Livewire morph every update triggers; the
active row drops its hover tint so hover:bg-* cannot paint over the marker.

The grid only answers keys when a row itself has the focus and stays inert
while a dialog is visible; closing a modal hands the focus back to the
active row. A Shift+range with no keyboard anchor (mod+A, a checkbox, the
select-all strip) now anchors at the far edge of the contiguous selected
block instead of collapsing the selection to two rows, and a checkbox
click sets the anchor.

Table::getActiveRowConfig() becomes the shared owner of the marker and the
row hover vocabulary (activeClass leaves the keyboard config). Ships a new
record-actions-keyboard workbench preview with a shortcut legend, a CDP
driver characterizing the behaviour, updated EN/CZ/boost docs, and the
selection-gestures plan documents.

Also covers the wizard-step form degradation when the form factory is
unbound (core seam follow-up).
Pint's fully_qualified_strict_types fixer rewrites a {@see} FQCN into a
use statement, which would reintroduce the core -> forms import that the
seam inversion deliberately removed. An inline-code reference carries the
same information without triggering the fixer.
No drift test existed on packages/table/dist/wire-table-records.js: a
forgotten npm run build:table-assets would leave every PHP test green
while the CDP drivers characterize a stale bundle. Mirrors the
DropdownAssetTest pattern — the bundle must ship, be served by the
package route, and carry the pointer, keyboard-grid and selection-gesture
layers of wireRecordActions.
Three variants over a new 40-row GestureRow fixture: selection-gestures
(one page, the document scrolls), selection-gestures-paged (20 a page)
and selection-only — selectable with no record action at all, the
variant that can prove grid semantics attach to selectable() itself
rather than riding along with record actions. SortablePreview becomes
selectable so the selection sweep and the reorder handles can be
verified coexisting on one table.
verify-selection-gestures.mjs pins today's behaviour (C1-C13 of the
rollout plan) against the gesture fixtures: checkbox toggle + anchor,
Space/Shift+arrow/mod+A, replace-semantics ranges, record actions from
the keyboard, the context menu, the bulk bar, all mode on a paged table
and the mobile cards. Three checks deliberately document bugs that later
steps flip consciously: Shift+arrow replacing the selection (16), a
range in all mode rewriting mode and collapsing the selection (15), and
the header checkbox in all mode inverting it (10). Also documents the
CDP modifier bitmask and the drag primitive later steps build on.
The stacked-selection preview boots with three records pre-selected, so
both of the page's cards are already checked and the first strip tap
clears the page — toggleAll's documented clear branch, not a failure.
The driver expected the tap to select and reported a phantom regression
on every gate run. Tap once to clear, once more to select.
RecordActionResolver::shortcuts() silently dropped reserved keys, so an
app shipping ->onKey('Enter') lost the gesture without a signal — and
widening the reserved list would have been a silent BC break. An
explicit onKey() on a reserved key now throws
TableConfigurationException; a shortcut merely stamped on the action
itself is still skipped, because it serves the action's other surfaces
and reusing that action in a table must not fatal the render.
ArrowUp/ArrowDown, Home/End, PageUp/PageDown, ContextMenu, F10 and ?
join Enter/Space on the reserved list, ahead of the steps that bind
them. Backspace stays deliberately bindable — it acts as a platform
alias of Delete in the client matcher, and reserving it in PHP would
make an explicit ->onKey('Backspace') impossible forever. Recorded as a
breaking change in the changelog: a shortcut on any of these keys was
already dead code, but it now fails loudly instead of silently.
Table::usesGridSemantics() becomes the single owner of the ARIA-grid
decision: record actions, selectable() and bulk actions (isSelectable()
covers both) all qualify, with recordActionKeyboard() staying the
explicit override in both directions. keyboardNavEnabled() delegates to
it, so a selectable table without record actions now renders role=grid,
role=row and the static first-row tabstop.

The wireRecordActions mount stays scoped to the record-action surface
behind its own owner, mountsRecordActionController() — widening it to
every grid changes what a click visibly does on existing selectable
tables and ships as the next step.
mountsRecordActionController() now includes usesGridSemantics(), so a
selectable table with no record action gets the same delegated
controller as one with them: a click marks the active row, the roving
tabindex binding takes over from the static tabstop, and Space,
Shift+arrow and mod+A drive the selection the checkboxes share. A row
click still never toggles the checkbox, and Enter stays a quiet no-op
when there is no primary action. Recorded in the changelog as a visible
change for every existing selectable table; recordActionKeyboard(false)
remains the opt-out. The gesture driver grows a selection-only section
proving the grid works without record actions.
The selection root carries a wire:key, so Livewire morphs it in place
and never re-evaluates x-data — a matching count seeded there stayed at
the first render's value forever: filter 128k rows down to 7, select
all 7, and the bulk bar still claimed the original count while the
server was already right. The count now lives in a data-matching
attribute the morph rewrites, read through a getter — the same pattern
pageKeys already uses. The gesture driver pins it: narrow the filter,
select the page, and the count follows.
selection.mode arrives through an entangled client write and nothing
validated it: the synthesizer's key filter only strips unknown top-level
keys, and mode is what decides whether selection.records is the
selection or the exclusions. A mode outside the two known shapes now
collapses to keys and drops the list with it — a list whose meaning
cannot be established would invert the selection if kept. The two
legitimate shapes pass through untouched, because the client writes
mode and records as a deliberate pair and wiping the list on a valid
flip would destroy the write it belongs to.
In all mode selection.records holds the exclusions, but the client
toggleAll() ran its keys-mode arithmetic over that list anyway: it
forced mode back to keys and kept exactly the rows the user had
unticked, so one header-checkbox click turned the selection into its
own complement and bulk actions read the inverted set. The header
checkbox now mirrors the server: a mixed page selects the page as an
explicit set (selectAllRecords), a fully selected page clears the whole
selection (deselectPageRecords), and exclusions never leak into keys
mode. The C10 characterization flips to pin the fixed behaviour in both
scenarios.
Three places answered 'select the page' three different ways: the blade
toggleAll cleared, the JS selectPage replaced, and the server unioned in
keys mode but narrowed an all-matching selection down to one page. The
canonical semantics is now pinned in PHP and mirrored by the header
checkbox: a page gesture never leaves all mode and never shrinks the
larger selection — selecting the page re-includes its excluded rows,
deselecting it records the page as exclusions, and everything off-page
stays selected either way. Narrowing to the page remains available as
the explicit selectOnlyPageRecords() control. The JS selectPage aligns
in the mode-rewrite step, which this pins the reference for.
The 54-line selection x-data blob leaves the publishable blade and ships
as a bundled Alpine factory (dist/wire-table-selection.js), so the logic
updates with the package instead of freezing in published views. The
x-data stays on the same wrapper element with the same hooks
(data-selection-root, data-page-keys, data-matching), so all four CDP
consumers pass unchanged — the whole network runs 80/80 with zero
behavioural assertions touched.

The factory is a function (Alpine binds a data factory's this to the
magic context carrying $wire) and both entangles are created in the
returned literal — in init() they would store the raw interceptor and
selection would silently stop syncing. PHP hands over the semantics
(statePath, syncLive, commitDelay) so they stay assertable server-side.

A missing bundle cannot take the whole table wrapper down: the asset
partial inlines the import-free source verbatim as the fallback, since a
dangling factory reference would kill Alpine for search, filters, bulk
bar, pagination and the modal hosts at once. The only test change is the
moved entangle literal in WithTablePerformanceTest — an artifact of the
move, not a behaviour change.
SelectionAssetTest guards the new bundle the way the records bundle is
guarded: shipped, served by the package route, carrying the whole
selection surface, and — because the asset partial inlines the raw
source as the missing-bundle fallback — the source must stay
import-free. SelectionRenderTest pins the mount contract: exactly one
wireRecordSelection on the wrapper, none of the selection hooks without
selectable(), and the assets include outside the table body, which is
not rendered without visible columns while the stacked cards still
select.
The anchor is selection state — what a range means depends on what is
selected, and every selection surface shares wireRecordSelection — so
anchorKey, anchorFor, selectRange and selectPage leave the record-action
controller, which now bridges through setAnchor()/selection(). Behaviour
is unchanged: the whole CDP network passes 80/80 with only the anchor
reads repointed at the selection component.

The markup gains data-selection-version: from this step the packaged
record-actions bundle reads state only the new component provides, and a
published view predating it still renders a selection root that answers
with an object — nothing would crash, ranges would just select wrong.
The bundle now refuses the stale markup out loud (console.error naming
the republish command) and falls back to the server toggle path.
selectRange no longer forces keys mode: in all mode the same block write
lands in the exclusions, so a Shift+range deselects the block and the
other matching records stay selected — before this, one Shift+arrow
collapsed an all-matching selection to a page block. selectPage becomes
a union like the header checkbox (the last of the three divergent
'select the page' answers aligns with the canon) and stands down in all
mode entirely, because mod+A is not a range gesture and unioning page
keys into the exclusions would deselect the page. C9 flips to pin the
fixed behaviour and a new check pins the mod+A guard.
selectRange now snapshots the selection outside the contiguous block
around the anchor on its first write and unions the snapshot with the
[anchor…active] block from then on: a block selected earlier survives a
disjoint range untouched, and the snapshot is what makes the range
shrinkable — a union alone is monotone. The snapshot is an explicit copy
(the entangle proxy would change under the write) minus blockAround, now
factored out of anchorFor.

The base dies with the anchor everywhere the anchor dies: setAnchor and
clearAnchor pair them, a plain arrow move clears both, selectPage drops
the base, the row observer clears them only when the anchor row actually
left the page (a page-blind clear would be wrong, a page-blind KEEP
would resurrect off-page rows into the next range), and a mode watcher
catches the escalation controls and the server's scope reset pushed down
on a filter change. onRowFocus deliberately stays out of that list —
activate() calls focus(), and clearing there would erase the anchor the
same gesture just set.
Shift+click ranges from the anchor (falling back to the block edge of
the row the user was on before the click, resolved before the click
marks it), mod+click toggles the single row and anchors it exactly like
a checkbox click — anywhere on the row — and mod+Shift+click adds a
whole block: selectRange grows an additive flag under which the base
keeps the full snapshot instead of ceding the block around the anchor.
A modifier click is a selection gesture, never an action gesture: it
returns before the click bindings, so it cannot fall through into a
bound record action. Verified with real CDP modifier clicks (meta on
macOS — ctrl+click is the secondary click there).
All new keys route through the same moveActive(), inside the existing
guard order — a key in an editable cell stays the cell's, a key under an
open dialog stays dead. mod+Shift+arrow means to-the-edge and lands on
the same indices as Shift+Home/End (one implementation); mod+arrow alone
stays unbound (macOS system territory), mod+PageUp/PageDown stays the
browser's tab switch, and the mod+A branch now checks shiftKey so
mod+Shift+A no longer selects the page.

The page jump derives from the row PITCH against the nearest ancestor
that actually overflows vertically: computed overflow-y is 'auto' on any
overflow-x-auto wrapper (CSS pairs the axes) and the table's own
horizontal scroller is exactly that, so without the scrollHeight check
PageDown reported the full table height and jumped straight to the last
row. A display:none table (mobile cards) reports zero pitch and falls
back to a single step instead of dividing into Infinity.
matchShortcut gains a second pass where Delete and Backspace are one
equivalence class (an exact binding still wins the first pass): the big
key on a Mac keyboard reports Backspace where the gesture means 'delete
this record'. A JS alias on purpose, never a PHP reservation — that
would forbid an explicit onKey('Backspace') forever, and the legend can
render the pair as one row.

Shift+F10 cannot go through the matcher (kb.shortcuts maps to action
names and opening the menu is not an action), so it is its own case
falling through to ContextMenu. A keyboard-opened menu now takes the
focus into its panel — dialogOpen() cannot see a role=menu surface, and
with the focus left on the row the arrows kept moving the marker behind
the open menu; closing hands the focus back through the existing rescue.
The _menuFromKey flag eats the native contextmenu a real browser fires
right after the key — headless Chrome never emits it, so that guard is
deliberately untestable in the CDP driver and marked as such.
The sweep gesture and the widened click target will find the selection
column by this hook, never by column position — sortable prepends a
drag-handle cell and would shift every index. The checkbox cell, its
mobile card counterpart, and the positional spacers in the summary
footer and the group subtotals all carry it, so the column stays one
addressable track through every row kind. Markup only, no behaviour.
createAutoScroller moves out of fill/ into resources/js/support/, and
the row geometry (bodyRows, rowAtY) is factored out of fill/grid.js into
support/rows.js with the grid delegating — the selection sweep is the
second drag-over-rows gesture and consumes the same helpers next,
cross-package. The fill controller behaviour is unchanged: the fill
driver passes 26/26 and the core dist is rebuilt and committed (the
dropdown drift test greps strings the move does not change, so the
rebuild is load-bearing here, not routine).
Mouse-only, additive, and armed in two phases: pointerdown in a
data-select-cell only remembers the row — no preventDefault, which would
kill the focus on the checkbox button underneath — and the gesture
engages on the first move that changes rows, so a plain click stays a
plain click. The swept span unions in through toggle() (an excluded row
in all mode is simply re-included) and backing up never deselects.

The drag's trailing click retargets to the common ancestor of the press
and release rows; a capture listener on the selection root kills it
one-shot, with a 150ms backstop — the click can land in a later task
than a 0ms timer, which is exactly how it first slipped through. Touch
is excluded (pointerType, button, isPrimary) and touch-action stays
untouched so a finger keeps scrolling the column. Auto-scroll and row
tracking come from the core support helpers, a page-level morph guard
mirrors the fill's (a poll landing mid-sweep would morph the rows out
from under the pointer), and prefers-reduced-motion switches the swept
transitions off. Verified end-to-end incl. coexistence with sortable's
drag handles and native text selection outside the column; the driver
re-measures drag waypoints because the bulk bar appearing mid-sweep
shifts the table.
Step-to-commit map for steps 0-22, the decisions made during execution
beyond the plan's letter, the gotchas later steps must not rediscover,
and the state of the verification net. Continuation starts at step 23.
The modal, confirmation, and slide-over shells hard-wired
x-data="{ show: @entangle(...) }", so nothing could open them without a
Livewire binding. All three now accept an optional openOn event name —
on the Htmlable objects and as open-on on the <x-wire-modals::*> tags:
with no wire:model binding, show becomes plain Alpine state and the
named window event opens the surface client-side. openOn is ignored
when a binding exists, keeping a single owner of show.

Missing wire:model on the tag path yields a WireDirective with value
false, and filled(false) is true — binding detection therefore goes
through value() with a strict [null, false, ''] check.

Verified end-to-end by the core-open-on workbench preview and
verify-modal-open-on.mjs (14/14): every shell starts hidden, the event
opens it, a Livewire roundtrip leaves it open and morphs the teleported
body, Escape/cancel/close still close it, and the listener survives the
morph. This is the core seam for the table's keyboard-shortcut help
(selection gestures rollout, step 23).
ONyklicek added 25 commits July 27, 2026 09:37
The selection-gesture work took wire-table from 87.x to 88.3%, so the
floor follows the debt down. Measured over a fresh clover report (4621
tests); the other four packages are unchanged.

Worth noting the margin: the floor gate is blocking in CI, which
measures with pcov while this was measured with xdebug, so 88 leaves
roughly a third of a point of headroom for the difference between the
two. That is in line with the other floors (forms sits 0.4 above its
own).
…d-out

The per-feature previews each isolate one thing on purpose — selection-only
proves grid semantics come from selectable() rather than from record
actions, sortable-columns proves a header drag leaves the fixed cells
alone. None of them says what happens when the features share a table,
which is where they can interfere.

The lab turns everything on at once: selection gestures, record actions
on all four triggers, a filterable column, reorderable columns, the
shortcut help, mobile cards. Beside it sits a panel reading the state the
gestures drive — mode, selection, anchor, range base, active row, the
last thing the live region announced, and the current column order — so
the table can be driven by hand and watched, and so the driver can assert
on rendered text rather than Alpine internals.

verify-gesture-lab.mjs (23/23) goes after the seams: a sweep followed by
a keyboard range over one selection, an action running against the row a
gesture marked, a column reorder while rows are selected (the checkbox
must not move and the selection must survive), the help listing this
table's own actions, "all matching" where a range deselects, and modified
clicks that never reach a bound action. It resets the persisted column
order at the end, so a second run does not start from the first one's
leftovers — verified by running it twice.

Two things the lab clarified rather than broke. The shortcut help lists
the context menu as one gesture, not each action inside it, which is
right — the menu is the shortcut. And the lab's filterable column gives
it two header rows, so aria-rowcount is 42 and the body starts at index
3: the header-row arithmetic behaving exactly as intended.
Reported: a single click opening a modal left the keyboard unusable,
during and after. Both halves reproduce, and both come from the same
gap — a modal shell never touched the focus.

During: the focus stayed where it was when the modal opened, which on a
grid table is the row behind the dialog. Tab therefore walked the page
behind the modal — measured, it cycled the row checkboxes — and the
dialog's own buttons were unreachable, so a modal opened by a row click
could not be operated from the keyboard at all.

After: closing it left the focus on whatever the tabbing had reached.
The grid only answers keys when a row itself has the focus, so the arrow
keys were dead until the user clicked a row again. Measured before the
fix: ArrowDown moved the active row from 6 to 6.

All three shells now move the focus into the dialog on open, cycle Tab
and Shift+Tab inside it, and hand the focus back to the element it came
from on close — only if that element is still in the document, since a
Livewire re-render may have replaced the row. It is one included partial
rather than three copies, and it is expression-only: a modal must not
depend on a JS bundle that a page rendering only modals would not load.

Two details worth keeping. The trap carries no double quotes, because a
double quote inside an Alpine attribute truncates it and the trap would
silently stop working — a test asserts the tail of each expression
survived rendering. And the focusable-element query filters on
tabIndex >= 0 in JS rather than a :not([tabindex='-1']) selector, for
the same reason.

Covered by four checks in verify-gesture-lab.mjs (27/27), all four of
which fail against the previous behaviour. Every other modal driver is
unchanged: nested-modal, modal-layering, confirmation-object,
modal-open-on, modal-mobile, wizard-live and select-floating.
Audit of the other modal surfaces after the modal focus fix. Most were
already fine: every bottom sheet and floating panel goes through
x-focus-trap in the dropdown bundle (verified — the filter sheet moves
the focus inside and Escape returns it to the trigger), the suspended
parent shell is inert and aria-hidden by design, and the tags panel is a
combobox, where the focus correctly stays in the input and the ARIA
pattern is aria-activedescendant rather than a trap.

The image cropper was not fine. It covers the viewport with its own
markup and had no role="dialog", no aria-modal and no focus handling at
all: measured, opening it left the focus on <body> and the first Tab
landed on the file picker behind the overlay. It now uses the same
shared partial as the modal shells, which grew an `openExpression`
parameter for surfaces that call the state something other than `show`.

The cropper also exposed a real weakness in the trap. Re-opening it in a
driver that had just confirmed a crop left the focus outside, because
focus() in $nextTick can run before x-show has made the surface visible,
and focus() on a display:none element silently does nothing. The trap
now pulls the focus in whenever it is outside — not only on the first
open — and tries again a frame later. Both halves were needed; neither
alone made the third cycle pass.

verify-image-crop.mjs is 9/9 with four new checks covering the dialog
semantics, the focus entering, Tab staying inside, and the focus leaving
on cancel. Every other modal driver is unchanged.
It reported 2/6 with "nothing reached the wire:model input", and had
been doing so before any of today's changes. Not a regression: the
driver covers the automatic path — imageCropAspectRatio() names a ratio,
so the picked file is cropped from the centre and lands in the
wire:model input on its own — but the preview it targets later opted
into cropInteractively(). With a frame to place, nothing reaches that
input until the user confirms, so the driver measured an empty input and
reported undefined dimensions.

The automatic path had therefore stopped being covered in a browser at
all, while the interactive one is covered by verify-image-crop.mjs. It
now has its own preview (field-file-upload-auto, the same processing
without the frame) and the driver targets that, with an added check
asserting no frame is waiting — which is the difference between the two
previews and the reason this broke quietly.

7/7, and the numbers match what the changelog recorded when the feature
landed: a 1000x1000 PNG comes out 320x180 at 16:9, 23 478 B to 1 953 B.
The processing was never broken; only its test had drifted off it.
Running all 42 drivers turned up two failures, both pre-existing —
verified by re-running them against the commit this session started
from. Neither was a bug in the product.

Both probed the action-group menu with querySelector('[role=menu]'),
which returns the FIRST in the DOM. Every row renders its own menu, so
that is some other row's, still hidden and measuring 0x0. phase3-sheet
therefore called a full-width bottom sheet "left=0 right=0" and swipe
decided the sheet had never opened. Measured properly, the sheet is
fixed, 390 wide, pinned to the bottom edge — exactly what the checks
were asking for.

Worth recording that my first attempt also "fixed" the desktop
assertion from absolute to fixed, on the strength of a position read off
that same hidden element. With the right element the desktop panel is
absolute, as the check always said. Reverted.

Also adds scripts/verify-drivers.sh (npm run verify:drivers) — one
server, every driver, a summary; optionally filtered by name. Running
them one at a time was a manual chore, which is how two of them stayed
red without anyone noticing.
Running all 42 in a row exposed a failure mode a single driver never
shows. Each one spawns Chrome on a fixed DevTools port and kills it on
the way out, but one that gets interrupted leaves the browser behind —
and the next run on that port connects to the SURVIVOR instead of its
own, then waits on a page that will never be what it expects. Measured:
one driver sat there for three and a half minutes and the sweep stopped
behind it.

The sweep now clears leftover headless Chromes before each driver (it
owns the whole run, so nothing else is using one) and puts a watchdog
around each, so a hang costs one driver rather than the sweep. Written
by hand rather than with timeout(1), which macOS does not ship.
…or to 92%

Column reordering had no server-side test at all: reorderColumns() takes
a list of column names from the browser and writes it to the database
per user, and every guard around that write — the feature being off, no
authenticated user to store it against, names the table does not define,
a filtered-empty list that must not read as "reset" — was unverified.
That is also the half the browser driver cannot see.

Ten cases cover it, plus one for the plugin hook's guard against a
payload that carries no Table (it runs for every table.querying payload
in the app, including ones it has no business touching).

sortable goes from 80.9% to 92.4%, so the floor moves 80 → 92. The other
four are unchanged: the ratchet rounds down, and none of them has crossed
its next whole point (core needs 20 more covered lines, forms 16,
table 38 — boost is at 100).

Test class names carry an Rco* prefix because the full suite loads every
test file into one process, so the name has to be unique repo-wide;
RcComponent was already taken by the table package and the collision only
shows up in the combined run, not in the sortable suite alone.
…rs to 95%

Four public vocabularies had no test at all, and each is the kind of code where
a mistake is invisible until it reaches a browser:

- getAlpineKeydownExpression() maps a shortcut onto Alpine's key modifiers.
  Now covered for every modifier and named key, plus the two answers that are
  not a binding: a modifier-only shortcut, and no shortcut at all.
- HasColor::getRowHoverClasses() is the canonical owner of the row hover tint,
  a 23-arm match that nothing exercised. Covered across the palette, the
  semantic aliases, and the adaptive black/white pair, which borrows the gray
  ramp because there is no bg-black-50.
- DateTimePicker resolves its format and first day of week from config. The
  tests pin the config themselves: the shipped default is d.m.Y, not Y-m-d,
  and the suite runs in random order, so a neighbour would otherwise decide
  the outcome.
- CheckboxList's bulk toggles and TextInput's type/step/mask/inputmode
  attributes are asserted on the rendered element, where a typo would show.

core 94.7% -> 95.3%, forms 94.9% -> 95.4%; both floors follow.
A date bound was passed through raw, and both consumers compare it as a
plain string. A native input drops a min that does not match its type
without a word, so ->native() and asMonth() lost it entirely; the custom
calendar compared it against a cell's Y-m-d, so 'today' greyed out every
day and a Carbon serialised to an ISO instant disabled its own boundary
day. Only a bare Y-m-d behaved.

DateBoundary now owns reading whatever the owner wrote — a
DateTimeInterface, '10.07.2026', 'today', '+1 week' — and reshaping it
into the exact format each surface compares against, splitting the day
half from the clock half. It encodes two rules rather than leaving them
to each caller: a day-granular upper bound covers the whole day, and a
bound that cannot be read throws where it is declared instead of failing
silently in the browser.

Three gaps closed with it: a datetime picker never checked the clock, so
a bound of '2026-07-10 08:30' let you pick midnight on that day; the
month arrows walked past the bounds; and an unset picker opened on today
even when today was years outside the range. DateFilter (and the
filterAsDate factories) fed the same unreadable bounds to their inline
input and now normalize through the same owner.

Verified in a browser by verify-datepicker-bounds.mjs (11/11).
A lazy() table deferred its markup to loadTable(), but emitted every
Alpine bundle from inside that deferred half. Each of them registers
its components from an alpine:init listener, and that event fires once,
when Alpine boots — so a bundle first emitted by the deferred render
arrived too late: the script ran, subscribed to an event that would
never fire again, and registered nothing.

The morphed-in markup then evaluated x-data="wireDropdown(...)" against
an undefined factory, killing every dropdown, the selection root and the
record controller. Worse, each sheet backdrop is an x-show="open" over
state that no longer existed, so all of them rendered instead of staying
hidden: a lazy stacked-card table came up as a page-covering scrim over
a dead table.

The placeholder branch now emits the bundles itself, gated as the loaded
table gates them, with the dropdown bundle unconditional because the
toolbar alone is built from dropdowns.

Covered by LazyTableAssetsTest, checked to fail without the fix, and
verified over CDP at 390px: the collapsed menu opens and the scrim count
goes from 7 to 0. Full driver sweep 43/43.
The "How It Works" list stopped at "page renders with the placeholder",
which reads as though the placeholder were only a skeleton. It is also
the render that has to carry the bundles: they register from alpine:init,
which fires once, so anything arriving with the deferred markup registers
nothing.

Documents which bundles load and on what condition, and that a custom
lazyPlaceholder() changes the visible skeleton only. EN + CS, mirrored
into the wire-boost bundle.
The bundle list said the controller loads "with record actions or
keyboard navigation". mountsRecordActionController() is broader than
that: pointer bindings, a row context menu, grid semantics, drag-select
or Shift-range selection each mount it on their own.
The performance bullet sold Table::lazy() as deferring the table, full
stop. It defers the query and the markup; the Alpine bundles ship with
the placeholder render either way, because they register from alpine:init
and that fires once at boot. Worth stating where an agent picks levers
for first paint.
… on a phone

The gesture layer built on this branch — keyboard grid navigation, Shift
ranges, the drag sweep, the right-click menu, `?` help, the fill handle —
turns a table into something closer to a desktop application. That is right
for a back office and wrong for a public page, and until now the only way out
was recordActionKeyboard(false), which took the mouse gestures with the
keyboard and nothing less.

Table::gestures() replaces it as the single owner of the decision:

    $table->gestures(false);
    $table->gestures(fn (TableGestures $g) => $g->keyboard()->dragSelect(false));

with the project-wide default in config('wire-table.defaults.gestures'), so a
site that wants none of this says so once. Each capability is a permission,
never a summons: allowing the sweep still needs selectable(), allowing the
fill handle still needs fillHandle(). What it does NOT govern is an explicitly
declared record action — a bound click or double-click is a statement about
that table and keeps firing with the layer off.

The second half is the phone. Every record trigger is a desktop one, so a
behaviour-only record action was unreachable in the stacked cards. They now
render it as an ordinary button (recordActionButtonsOnMobile(false) opts out),
which is what lets one table be an application on a desktop and a plain list
on a phone — one action declaration, two ways in.
…nt the layer

The stacked cards are in the document at every width, so rendering a record
action there rendered a real button behind the desktop table — and a button
binds its keyboardShortcut() as a WINDOW listener. One Delete press therefore
ran the onKey('Delete') action once per card on top of the grid's own hit; the
CDP driver caught it as a confirmation modal that would not close, and the grid
going inert behind it for the rest of the run.

The card now renders a copy with the key taken off, through a new canonical
HasKeyboardShortcut::withoutKeyboardShortcut() — the shortcut belongs to the
grid, and a phone has no key to press anyway.

With it: verify-gestures-off.mjs (21 checks) drives the switch in a real
browser, because the thing worth proving is not that a flag is absent from the
markup but that the gestures are DEAD — arrows move nothing, Shift+click takes
one row instead of a block, a drag down the checkbox column paints nothing,
right-click gets the browser's menu back, ? opens no help — while a declared
double-click still opens its modal and a phone gets its buttons. A control pass
over the same table with the layer on keeps those checks honest. New fixture:
/previews/gesture-lab-plain.

Docs in both languages (table/gestures.md), the upgrade note, the changelog and
the boost guidelines; the passages that named recordActionKeyboard() now name
the switch that replaced it.
Keyboard navigation and the drag sweep are now off until a table calls
gestures(). They are the two capabilities that change how a table answers
someone who never meant to operate it: the rows enter the tab order, an active
row is marked, ctrl+arrow starts moving something, and a press in the checkbox
column turns into a block selection — found by accident long before it is found
on purpose. Neither belongs on an ordinary page by default, and both are exactly
right once a table says it is an application:

    ->gestures()

The rest of the layer stays allowed from the start, because each already needs
an invitation of its own: ranges need selectable(), the context menu needs
actions bound to it, the fill handle needs fillHandle(), and the ? help needs
the keyboard layer this default leaves off. A back office turns the whole thing
on once with 'gestures' => true in config, and gestures(false) still means
nothing at all.

TableGestures::defaults() is the new starting point; all() is what gestures()
hands over, and it leaves keyboard at null rather than true — a table with
neither record actions nor a selection has nothing for the arrows to do, and a
roving tabindex over inert rows is worse than none.

The workbench fixtures that exist to exercise the layer now ask for it, and the
gesture lab grew a read-out of what the table actually offers — a permission and
its prerequisite are different things, and "allowed but nothing to do" is the
state people misread. Both drivers assert it. Docs, upgrade guide, changelog and
boost guidelines follow.
The reference pages carried the shape of the feature but not enough of it to
work from: what a table gets without asking, what each reader answers, and what
to do when a gesture is missing rather than broken.

docs/table/gestures.md (and its Czech mirror) now carry the full API surface —
every setter, every reader, and the difference between allows*() (a permission)
and uses*() (a permission whose prerequisite is also met, which is the pair
people misread) — plus six recipes from a public listing to a shared house
style, and a troubleshooting table keyed by symptom: arrows do nothing, ?
opens nothing, a drag selects nothing, Shift+click takes one row, right-click
gets the browser's menu.

boost had grown a Gesture layer section of its own from the earlier pass and a
second, shorter one from mine. They are now one section, on the opt-in default,
with the capability/default matrix, the reader list, and the rule the mobile
fallback exists to keep: never render the same shortcut-carrying action on two
surfaces, because a rendered button binds its shortcut as a window listener.

Both overviews now link the three row-interaction pages, which nothing did.
The catalog listed every table concern except the one that decides what a row
answers to, and the runtime recipe said nothing about the layer being opt-in —
so the next change would reach for a local flag, and the next fixture would
assert grid semantics on a table that never asked for them. Both now say where
the decision lives and what a new capability costs (a setter, an allows*()
reader, a uses*() reader that folds in the prerequisite, a line in the client
config). CLAUDE.md routes row-interaction work there in the first place.
Range selection joins keyboard navigation and the drag sweep on the opt-in side.
It belongs there for the same reason: it silently re-reads a gesture the visitor
already knows. Shift+click stops being a click and becomes "everything between
here and the last one" — correct in a file manager, startling in a list of blog
posts, and impossible to discover except by accident.

So a selectable table now starts as checkboxes and nothing else. It no longer
mounts the delegated controller at all, since with the keyboard, the ranges and
the sweep all waiting there is nothing left for it to do — one fewer Alpine
component on every ordinary listing. The selection cell answers a modified click
by toggling, because with ranges off nobody else would.

What stays allowed by default is only what a table declared for itself: a
right-click menu when an action is bound to one, and the fill handle when it
asked for one.
…here they are the point

The previews had drifted from the default they are supposed to demonstrate.
usersTable() serves most of them — table-overview included, the one people open
to see what a table *is* — and it was asking for the whole gesture layer, so
every preview looked like an application. Only the three record-action variants
do now; everything else shows what a consumer gets out of the box.

The lab gained a third variant, because "never asked" and gestures(false) are
genuinely different states and the difference is the one people get wrong: the
shipped default still answers a right click, since the table declared that menu
itself. /previews/gesture-lab-default sits between the full lab and the plain
one, and the read-out spells out which of the three the current page is.

verify-gestures-off drives the new variant (25 checks): the three row gestures
off, the declared menu still opening on a real right click.

The first cut of the usersTable change was dead code — an if after the return
that ends the fluent chain — so the record-action previews silently lost their
gestures. verify-record-actions (7/14) and verify-record-active-row (exit 2)
caught it; both are back to 14/14 and 18/18, dual 5/5, mobile-selection 13/13.
The grid entry was filed under Breaking Changes, which it stopped being the
moment the layer became opt-in: nothing changes for a table that never calls
gestures(). It now sits in Added, next to the switch it describes, and Breaking
Changes is left with the one entry that really is breaking — onKey() on a
navigation key throwing instead of silently losing the binding.

wire-core's withoutKeyboardShortcut() was only mentioned inside the mobile
fallback that needed it. It is public API on a shared concern, so it gets its
own line, with the reason it exists: a rendered button binds its shortcut as a
window listener, so a second surface answers the same key a second time.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@ONyklicek
ONyklicek changed the base branch from main to 1.x July 28, 2026 05:35
@ONyklicek
ONyklicek merged commit d93d281 into 1.x Jul 28, 2026
33 checks passed
@ONyklicek
ONyklicek deleted the 1.14.0 branch July 28, 2026 06:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant