diff --git a/AI_COMPONENT_CATALOG.md b/AI_COMPONENT_CATALOG.md index d5ddcebc..57c72a3e 100644 --- a/AI_COMPONENT_CATALOG.md +++ b/AI_COMPONENT_CATALOG.md @@ -455,6 +455,15 @@ Main concerns: - `Concerns\HasSqlDebug` - `Concerns\HasSubRows` - `Concerns\HasRecordTriggers` — record-action trigger vocabulary (on the `RecordAction` wrapper) +- `Concerns\HasGestures` — the table's side of the gesture layer + +Gesture layer (which desktop pointer/keyboard gestures a table offers — OPT-IN): + +- `Support\TableGestures` — the canonical vocabulary: `keyboard` (3-state), `rangeSelection`, `dragSelect`, `contextMenu`, `shortcutHelp`, `fillHandle`; `defaults()` (shipped: keyboard + dragSelect OFF) / `all()` / `none()` / `fromConfig()` +- `Concerns\HasGestures` — `Table::gestures(bool|Closure|TableGestures)`, `getGestures()`, and the effective readers `usesDragSelect()` / `usesRangeSelection()` / `usesShortcutHelp()` / `usesActiveRowMarker()` / `getGestureConfig()` +- Consumers to keep honest: `Table::usesGridSemantics()`, `mountsRecordActionController()`, `hasRowContextMenu()`, `isFillHandleEnabled()`, `Support\TableShortcutLegend` +- Project default: `config('wire-table.defaults.gestures')` — `null` shipped default / `true` all / `false` none / map +- Rule: a capability is a **permission, not a trigger**, and an explicitly declared record action is outside the layer (only `onKey()` needs the keyboard) Record actions (whole-row interaction — click/dblclick/right-click/keyboard): @@ -464,6 +473,7 @@ Record actions (whole-row interaction — click/dblclick/right-click/keyboard): - `Actions\RecordActionResolver` — trigger→name pointer map, context-menu + row-button contributions, keyboard primary/secondary/shortcuts - JS controller: `packages/table/resources/js/record-actions.js` → `wireRecordActions` (one per ``); delivered via `packages/table/dist/wire-table-records.js` + `partials/record-actions-assets` - Deprecated alias: `Table::rowContextMenu()` → prefer `recordAction()->onContextMenu()` +- Mobile fallback: `Table::getMobileRowActionsForDisplay()` / `hasMobileActions()` / `recordActionButtonsOnMobile(bool)` — behaviour-only bindings render as ordinary buttons on a stacked card (copies with `HasKeyboardShortcut::withoutKeyboardShortcut()`, since a rendered button binds its shortcut as a *window* listener) Services: diff --git a/AI_RECIPES.md b/AI_RECIPES.md index ac90a605..2d042938 100644 --- a/AI_RECIPES.md +++ b/AI_RECIPES.md @@ -261,6 +261,15 @@ composer test:table Use for sorting, pagination, search, row actions, selection, modals, grouping, sub-rows, polling, summaries, and Livewire state behavior. +Gesture-layer rule (keyboard nav, ranges, drag sweep, context menu, `?` help, +fill handle): never add a local flag for one. `Support\TableGestures` + +`Concerns\HasGestures` own the decision, the layer is **opt-in** (keyboard and +drag sweep are off until a table calls `->gestures()`), and any fixture, preview +or test that asserts grid semantics has to ask for them first. A new capability +is a setter plus an `allows*()` reader there, an effective `uses*()` reader that +folds in the prerequisite, and a line in `getGestureConfig()` if the client +needs it. + Read first: - `packages/table/src/Concerns/WithTable.php` diff --git a/CHANGELOG.md b/CHANGELOG.md index b69a2c09..7e110cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to the Wire ecosystem will be documented in this file. +## [1.14.0] + +### Added +- **The desktop gesture layer is opt-in, and switchable capability by capability — `Table::gestures()`.** Keyboard grid navigation, range selection and the drag sweep down the checkbox column are **off** until a table asks for them: each 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), which is right for a back office and wrong for a public listing. A selectable table therefore starts as checkboxes and nothing more, and does not even mount the delegated Alpine controller. `->gestures()` is the call that 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 — no bindings, no marker, and the delegated Alpine controllers not rendered. The rest stays as it was without asking: the checkboxes, the select-all controls, the bulk bar, the right-click menu and the fill handle (the last two already required an invitation of their own). `config('wire-table.defaults.gestures')` sets the project-wide default — `null`/absent keeps the shipped one, `true` turns the layer on for every table, `false` allows nothing, a map such as `['keyboard' => true, 'drag_select' => false]` mixes, and an unknown capability throws rather than doing nothing quietly. `NyonCode\WireTable\Support\TableGestures` owns the vocabulary (`defaults()` / `all()` / `none()`; `keyboard()` is three-state — `false` by default, `null` hands the decision to the table, `true` forces it). Every capability is a **permission, not a trigger**: allowing the sweep still needs `selectable()`, allowing the fill handle still needs `fillHandle()`. What the switch does *not* govern is an explicitly declared record action — a bound `onClick()` / `onDoubleClick()` is a statement about that table and keeps firing (the exception being `onKey()`, which needs a keyboard layer to listen with). It is off on the server too: no `role="grid"`, no roving `tabindex`, no context-menu panels in the markup, `fillTableCells()` refused, and the `?` legend drops the rows it no longer applies to, so it can never promise a key that answers nothing. See `docs/table/gestures.md`. +- **A table that asks for gestures becomes an ARIA grid with live keyboard selection.** `Table::usesGridSemantics()` is the single owner of the decision: once `gestures()` is on, record actions, `selectable()` and bulk actions all qualify. Rows of such tables render `role="row"` with a roving tabindex and become focusable; clicking a row marks it as the active row (default marker `bg-primary-100 dark:bg-primary-900/30`, override with `activeRowClass()`), the marked row suppresses its `hover:bg-*` tint while marked, and the arrow keys, `Space` (toggle selection), `Shift`+arrows (range) and `mod`+`A` (select page) work against the same selection state the checkboxes use. A row click only ever *marks* the row — it never toggles the checkbox. Opt back out per table with `gestures(fn ($g) => $g->keyboard(false))`, which removes the grid semantics while the mouse gestures stay, or with `gestures(false)` for the whole layer. +- **An action can hand out a copy that does not answer its keyboard shortcut — `HasKeyboardShortcut::withoutKeyboardShortcut()` (wire-core).** A rendered action button binds its `keyboardShortcut()` as a **window** listener, so the same action rendered on a second surface answers the same key twice over — and a surface that is present but not shown (the stacked mobile cards behind a desktop table) answers it invisibly, once per record. A surface that must not own the key now clones the action and strips it; the original, and the grid that fires it against the active row, are untouched. +- **Behaviour-only record actions now reach a phone — as ordinary buttons.** Every record trigger is a desktop one (a finger has no double-click, no right-click and no `Delete` key), so an action bound only to a gesture used to be unreachable once the table stacked into mobile cards. The card now renders it as a button, and only there — the desktop table is unchanged, which is what lets one table be an application on a desktop and a plain list on a phone from a single action declaration. Nothing doubles: an action already in `->actions()`, one merely referenced by name, and one promoted with `->alsoInRowActions()` all yield exactly one button, and the fallback buttons count towards `->collapseActionsOnMobile()`. The copy the card renders drops the action's `keyboardShortcut()` (new canonical `HasKeyboardShortcut::withoutKeyboardShortcut()`), because a rendered button binds its shortcut as a *window* listener and the cards are in the document at every width — without it, one `Delete` press ran the action once per card behind the desktop table. Opt out with `->recordActionButtonsOnMobile(false)`. +- **Modal shells can now be opened from JavaScript — `openOn:` on every canonical shell.** The modal, confirmation, and slide-over shells (both the Htmlable objects and the `` tags via `open-on`) accept an optional window-event name: with no `wire:model` binding, `show` becomes plain Alpine state and dispatching the named event (`window.dispatchEvent(new CustomEvent('my-event'))`) opens the surface purely client-side — no Livewire roundtrip. Escape, the close/cancel buttons, and click-away still close it, and a Livewire update while it is open leaves it open and morphs its teleported body. `openOn` is ignored when `wire:model` is present, so an entangled modal keeps a single owner of `show`. This is the core seam for the upcoming keyboard-shortcut help. +- **A table can now describe its own keyboard gestures — `Table::shortcutLegend()`.** The legend is assembled from the table's own configuration and returned as data, not markup: localized sections (navigation, selection, actions, help) of `ShortcutHint` rows, each pairing equivalent keys with what they do. A plain data table returns an empty legend, `selectable()` adds the selection section, record actions contribute their Enter / Shift+Enter targets, the context menu, and every `onKey()` binding (a `Delete` binding also lists `⌫`, mirroring the client matcher's platform alias). Two new canonical owners back it: `NyonCode\WireCore\Foundation\Support\ShortcutLabelFormatter` formats a shortcut string for display and is now the single owner of that mapping — `HasKeyboardShortcut::getKeyboardShortcutLabel()` delegates to it, so an action label and a legend row always read the same, and arrow / paging keys finally render as glyphs (`Ctrl+↑`) instead of the old `Ctrl+ARROWUP`. `mod` resolves per platform at display time (`Ctrl+S` / `⌘S`) rather than being hard-coded to Ctrl. `NyonCode\WireCore\Foundation\ValueObjects\ShortcutHint` is the legend row itself, reusable by any surface that needs one (command palette, wizard, forms). +- **`?` opens the keyboard-shortcut help.** With a row focused, `?` shows what that particular table answers to — the legend above, rendered as a modal. It costs nothing until asked for: the grid controller dispatches a window event and the modal shell opens client-side, with no Livewire roundtrip. The key is matched on `event.key`, so it works on a layout where `?` is `Shift`+`,` (Czech) as well as on `Shift`+`/`; the same focus guard as every other grid key means a `?` typed into the search box or an inline-edit cell is left alone. Key labels render their Ctrl form server-side and swap to the Mac glyphs in the browser, where that is the platform. A table without grid semantics renders no help at all — which now includes every table that never called `gestures()`; `gestures(fn ($g) => $g->shortcutHelp(false))` drops the help while keeping the keyboard. +- **A grid table now describes itself to assistive technology, and announces what the selection does.** `role="grid"` used to be the whole story; the table now also carries `aria-rowcount` and `aria-multiselectable`, and every row an `aria-rowindex` counted through the **entire result set** rather than the page — so row 1 of page 2 is row 12, not row 1 again, and the column-filter row counts as the header row it is. Rows report `aria-selected`, bound rather than printed, so it keeps telling the truth across a Livewire morph instead of snapping back to the server's state. Selection changes are spoken through a polite live region that sits in the DOM from the first paint and starts empty: it announces "3 of 40 selected", "All 40 selected" when the selection escalates to everything matching, and "Selection cleared" on the way back down — the one message a region living inside the bulk bar could never deliver, since that bar is hidden at exactly that moment. New translation keys: `selection_announce_some`, `selection_announce_all`, `selection_announce_none`. +- **A date bound can now be written the way you would say it — `NyonCode\WireCore\Foundation\Support\DateBoundary` is the canonical owner.** Every surface with a min/max date (the picker, the date filter, a date column's header filter) accepts a `Carbon`/`DateTimeInterface` or any string PHP can read as a date — `'2026-07-10'`, `'10.07.2026'`, `'today'`, `'+1 week'` — and the shared owner reshapes it into the exact format that surface compares against (`Y-m-d`, `Y-m`, `H:i`, `Y-m-d\TH:i`), splitting the day half from the clock half for the consumers that need both. Two rules it encodes rather than leaving to each caller: a **day-granular upper bound covers the whole day**, so `->maxDate('2026-07-20')` on a `datetime` picker leaves 20 July selectable up to 23:59 instead of collapsing to its first second (a `date`-only widget drops the time anyway, and on a `time` widget midnight is a deliberate choice, so neither is stretched); and a bound that cannot be read at all now throws `InvalidDateBoundaryException` at the point it is declared, because the alternative is a calendar that looks right and behaves wrongly. `minDate()` / `maxDate()` on `DateTimePicker`, `DateFilter`, `Column::filterAsDate()` and `filterAsDateRange()` all widen to `string|DateTimeInterface|null` (plus `Closure` where they took one), which is a purely additive change. +- **The whole selection cell is now the checkbox's click target, and the active row is marked by more than a colour.** The 16×16 box was the only thing you could hit in a 64×59 cell, well under the 24×24 minimum; clicking anywhere in the cell now toggles the row, and the cell is registered as interactive so that click can never fall through into the row's record action. The active-row marker keeps its background tint but adds a stripe down the row's leading cell: the tint alone measures about 1.1:1 against a plain row — under the 3:1 non-text contrast floor, and no signal at all to a reader who cannot separate the two hues — while the stripe measures 4.3:1 against the tint and 4.8:1 against a plain row. It is drawn as an overlay, so nothing shifts when a row is marked. `activeRowClass()` still replaces the marker outright. + +### Fixed + - **`Table::lazy()` left the loaded table dead under a stack of full-screen scrims.** A lazy table renders a placeholder and defers the real markup to a `loadTable()` roundtrip — but every Alpine bundle the table needs (`dropdown.js`, `selection.js`, `records.js`) was emitted from *inside* the deferred half, so the placeholder render shipped none of them. Each bundle registers its components from an `alpine:init` listener, and that event fires exactly once, when Alpine boots: a bundle first emitted by the deferred render arrives after it, so Livewire injected the script, the script ran, it subscribed to an event that would never fire again, and **nothing was ever registered**. The morphed-in markup then evaluated `x-data="wireDropdown(...)"` against an undefined factory — `ReferenceError: wireDropdown is not defined`, and the same for `isSelected` / `toggle` — killing every dropdown, the selection root and the record controller. The visible symptom was the worst part: each sheet backdrop is an `x-show="open"` over state that no longer existed, so instead of staying hidden **every one of them rendered**, and a lazy stacked-card table came up as a page-covering grey scrim with a dead table under it. The placeholder branch now emits the bundles itself — gated as the loaded table gates them (`selectable()`, the record controller), with the dropdown bundle unconditional because the toolbar alone is built from dropdowns — so they land before Alpine boots and the deferred markup initialises normally. Covered by `LazyTableAssetsTest` (checked to fail without the fix) and browser-verified over CDP at a 390px viewport: the collapsed `⋮` opens its menu, and the scrim count goes from 7 to 0. +- **`DateTimePicker::minDate()` / `maxDate()` only worked if you happened to write the bound in the widget's own format — otherwise it was dropped, or it disabled the entire calendar.** The bound was passed through raw, and both consumers compare it as a plain string. A native input ignores a `min` that does not match its type outright and says nothing: `` is invalid, so `->native()` and `asMonth()` lost the constraint entirely. The custom calendar ran `dateStr < this.minDate` against a cell's `Y-m-d`, so `->minDate('today')` compared digits against letters and greyed out *every* day, `->minDate(fn () => now())` serialised to `2026-07-27T12:00:00Z` and disabled its own boundary day, and `'10.07.2026'` was nonsense in either direction. Only a bare `Y-m-d` string behaved, which is why it read as intermittent rather than broken. Bounds now go through `DateBoundary` (below) and are reshaped to the widget's own format before they reach either surface. Three further gaps closed with it: a `datetime` picker **never checked the clock at all**, so a bound of `'2026-07-10 08:30'` let you pick 00:00 on that day — the time half of a bound now binds on its own boundary day, pulling the clock back inside the range as the value is committed; the month arrows walked straight past the bounds and are now disabled at the edges; and an unset picker opened on today even when today was years outside the range, which reads as a calendar of nothing but greyed-out days — it now opens on the nearest month the bounds allow. `disabledDates()` had the identical string-comparison bug and is normalized the same way. Verified in a real browser by `workbench/scripts/verify-datepicker-bounds.mjs` (11/11) against a new `/previews/field-date-time-picker-bounds` preview: days outside the range refuse to be picked, both boundary days stay selectable, the clock clamps on each of them and moves freely between them, and the arrows stop at the edges. +- **The table's `DateFilter` handed the same unreadable bounds to its inline ``.** `->minDate()` / `->maxDate()` on a date filter (and `Column::filterAsDate()` / `filterAsDateRange()`, which are thin factories over it) were stored and rendered verbatim into the header filter's native input, where anything that is not `Y-m-d` — `Y-m` for month mode — is silently discarded by the browser. The docs' own `->maxDate(now()->format('Y-m-d'))` worked only because it pre-formatted by hand. The filter now normalizes through the same owner and its setters take a `DateTimeInterface` as well, so `->maxDate(now())` says what it means. +- **The image cropper was a dialog that behaved like part of the page.** The crop overlay covers the whole viewport but carried no `role="dialog"`, no `aria-modal`, and no focus management: opening it left the focus on ``, and Tab reached the file picker *behind* the overlay before it reached the crop buttons. It is now announced as a modal dialog, takes the focus when it opens, keeps Tab inside, and hands the focus back when it closes — through the same shared implementation as the modal shells. +- **A modal left the keyboard unusable — during and after.** A modal shell never took the focus when it opened, so it stayed wherever it had been: on a grid table, the row behind the dialog. Tab then walked the page *behind* the modal instead of its buttons, which meant a dialog opened by a row click could not be operated from the keyboard at all; and when it closed, the focus was left on whatever the tabbing had reached — usually a checkbox — so the table's arrow keys, which only answer when a row itself has the focus, stayed dead until the user clicked a row again. All three shells (modal, confirmation, slide-over) now move the focus into the dialog on open, keep Tab and Shift+Tab cycling inside it, and hand the focus back to the element it came from on close, provided that element still exists. One shared implementation, not three. +- **Dragging a column header moved the checkbox column instead of the column's data.** The header move was mirrored onto the body by the index Sortable reported, which assumes a body row is the header row with different tags. It is not: a row leads with the teleport `