Conversation
… 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).
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.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, aShift+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.config('wire-table.defaults.gestures'); an unknown capability throws rather than doing nothing quietly.selectable()), and an explicitly declared record action is outside the layer — a boundonClick()keeps firing with the layer off; onlyonKey()needs the keyboard.role="grid", no rovingtabindex, no context-menu panels in the markup,fillTableCells()refused, and the?legend lists only what the table actually answers to.openActionModalpipeline — no second lifecycle — with one delegated controller per<tbody>rather than per-row Alpine islands.Deletekey, so the stacked card renders the same action as a button, and only there. Nothing doubles, and the card's copy drops the action'skeyboardShortcut()— a rendered button binds it as a window listener, so without that oneDeletepress ran the action once per card.aria-rowcount/aria-rowindexover 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, sominDate()/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
Shift-clicking a checkbox selected one row where the same click on the row body selected the range.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— greenvendor/bin/pest --testsuite "Integration"— greencomposer lint,composer analyse— cleancomposer coverage:verify— every package at or above its floor, diff coverage completenpm run docs:check,npm run docs:api— cleannpm run verify:drivers— 44/44 CDP drivers, includingselection-gestures77/77,gesture-lab28/28,gestures-off25/25,record-active-row18/18,fill-handle26/26Docs ship in both languages (
docs/anddocs/cs/), with the boost guidelines and doc mirror in sync.