Skip to content

[6.x] Modernize the Matrix block code path - #19599

Draft
brianjhanson wants to merge 31 commits into
6.xfrom
feature/matrix-pass
Draft

[6.x] Modernize the Matrix block code path#19599
brianjhanson wants to merge 31 commits into
6.xfrom
feature/matrix-pass

Conversation

@brianjhanson

@brianjhanson brianjhanson commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Description

Brings the Matrix field's block-creation path onto the Vue/Inertia form stack, and closes the parity gap with Craft 5's blocks that opened up along the way.

The block, and how it gets made. The server creates the blocks, the same way Craft 5 did. Clicking an add button shows a loading state while matrix/create-entry persists the entry as a draft and hands back its form nodes, which render through FormNodeList like any other form — markup never crosses the wire. Nested-element deltas posted as entries/sortOrder now normalize through one ElementHelper::nestedElementDelta(), which is what fixed the original perpetual spinner: the browser prefixed both halves of the envelope while the parser was written to Twig's shape, where only the keys carry uid:.

One card frame. Blocks and the element index render through a shared SelectableCardList — the card, its select checkbox, the drag handle and drop shadow — so selection, drag-sort and keyboard navigation are one implementation rather than two. craft-card grew a collapsed state so a folded block shows nothing but its header.

Craft 5 parity. The block menu is whole again: Collapse/Expand, Disable/Enable, Open in a new tab, Entry type settings, Delete, Duplicate, Copy, Paste above, Add {type} above. The server decides which items exist and what the user may do; the browser resolves what only it knows — the stateful pairs swap as the block folds, Add and Duplicate go when the field is full, Paste appears when the clipboard holds something that fits, and anything acting on a selection says so. Blocks carry their entry type's name, icon and colour, and a folded one shows its UI label or a summary built from its own fields. The add row carries each type's icon and colour and collapses into a grouped, searchable menu.

Errors this pass turned up. A nested element that was already a draft was being drafted again on save — "Cannot create a draft from another draft or revision" — which killed the autosave and took the edit with it. And a control could render a beat before its values reached the tree, so anything that reached into its value threw during render and the field disappeared until a reload. That one is fixed structurally: a Control declares its emptyValue(), the payload carries it, and controlValueAt() applies it at the two places a value reaches a control, so no control has to guard itself.

Also carries the bugfix-pass work this branch was built on: combobox option keying and change semantics, the relation field's target-site picker, the field-type rebuild loader, currency label heights, and language-aware case modification.

Still open, and why: Copy/Paste works through Craft.cp's clipboard but the field's own ownerId is baked into the payload at compile time, so it goes stale the moment a provisional draft appears — Craft 5 avoided that by flushing the field before minting, which needs a channel from a Control down to the editor that doesn't exist yet. Changing the block list still tears craft-matrix-input down and rebuilds it, because a block can hold a control that relocates its own light DOM and patching the list around one throws; the scroll position is held across that rebuild rather than the rebuild being removed. Both are noted in the code.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8

brianjhanson and others added 30 commits September 4, 2026 14:22
The browser posts a Matrix/Addresses field as an {entries, sortOrder}
envelope, but the two CP stacks disagree on where the `uid:` prefix
goes: block.twig prefixes the entries keys and leaves sortOrder bare,
while the Form controls prefix both. Matrix only ever stripped the
entries keys, so a Form control's prefixed sortOrder matched nothing and
every new block was silently dropped — the "Add block" spinner never
resolved because the block came back missing from the save.

Both fields now normalize through ElementHelper::nestedElementDelta(),
which detects the identity kind from either half and strips the prefix
off both. Fixes a matching latent bug where a reorder-only delta (a
sortOrder with no entries) failed UID detection and dropped everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
A block minted in the browser is keyed `uid:<uuid>` in the values tree,
but the server strips that prefix and scopes the block's nested Form to
the bare UUID. MatrixControl's forms map registers both keys, so the
nested Form resolves — while a control inside it still resolves its path
against the prefixed key and gets undefined. A repeater nested in a
block (Addresses, or another Matrix) then read `.sortOrder` off that and
threw, and FormRenderer swapped the whole form for a render error until
the next pass lined the keys up:

  Failed to render Form Control [...Controls\Matrix] with component
  [craft:matrix] at [fields.pageBuilder.entries.<uuid>.fields.addresses]:
  Cannot read properties of undefined (reading 'sortOrder')

Reads now go through a guarded `model`, so the control renders empty for
that beat instead of tearing the form down. The key mismatch behind it
still stands — dropping the `uid:` prefix is the actual fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Craft 5 blocks had a "⋮" menu; the Form control had a lone delete button.
This lands the six items that need no new infrastructure: Collapse /
Expand, Disable / Enable, Open in a new tab, Entry type settings, Add
{type} above, and Delete. Duplicate and Copy / Paste still wait on a
modern owner for the CP element clipboard.

The menu is built in Matrix::blockActions(), so both render paths get the
same items and the admin / allowAdminChanges checks stay on the server.
Behavior travels per item as a declarative action descriptor, the way
field menus already work. `runAction()` dispatches those on window, so
the Control scopes them by the invoking element.

Collapse and Disable read stale from the server the moment you toggle
one, so MatrixControl drops the server's copy of that pair and resolves
it against live state instead. A block the browser just minted has no
server-built menu at all, so it composes the half needing no server data.

Also in scope, because the items don't work without it:

- formControl() queried with the default status, so disabling a block
  made it vanish from the editor. It now resolves the value the way
  blockInputHtml() does.
- `collapsed` has no column. The posted value is echoed back onto the
  entry so a collapsed block survives an autosave instead of springing
  open. It still resets on reload — Craft 5 remembered that in
  localStorage, which is not ported yet.
- craft-action-menu assigned every item key as a property, so the
  `attributes` key every server-built descriptor carries threw mid-render
  and left the whole menu blank. Guarded, and craft-action-item now takes
  target/rel so "Open in a new tab" can be a real link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Drag-sort was still Garnish DragSort, bound by the legacy MatrixInput to
`.matrixblock` nodes the Form control re-renders — so the handle did
nothing. Blocks weren't selectable at all.

Both now come from the composables the element index already uses:
useReorderableItems and useSelectable, wired the way ElementCards.vue
wires them onto the same craft-card. DragSort is switched off in
form-control mode so there's one drag engine, not two, and the reorder
button moves blocks through the control's value instead of the DOM.

Selection brings back Craft 5's `bulkActionMode()`: a menu action on a
block that's part of a multi-selection applies across the selection.
Clicks inside a block are left alone by useSelectable's own
isInteractiveClick guard.

Collapsed state moves to localStorage, under the key and comma-joined
format Craft 5 used, so upgrades keep their collapsed blocks. It's a view
preference — posting it would mark the form dirty and trigger an autosave
just for hiding fields. A block the browser just minted has no identity
the server knows, so its collapsed state still rides in the posted value
until a save adopts it, the job Craft 5's hidden `[collapsed]` input did.
The storage itself lives in one module the legacy statics now delegate to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Follows Craft 5: clicking an add button shows a loading state while
matrix/create-entry persists the block as a draft and hands it back. What
comes back is form nodes rather than rendered HTML, so the block renders
through FormNodeList like every other form in the CP — no appendHeadHtml,
no initUiElements, no HTML to splice.

The identity is the server's, and it's a bare UUID. Nothing is minted in
the browser, so there's no `uid:` key to reconcile against the scope the
server returns — the class of bug behind the render error this pass
started with. The block's own field values ride in the same emit as its
identity; written straight into `values` they wouldn't survive, since the
Control's value is written back wholesale at its own path.

The browser still mints when the server can't offer a create config — an
unsaved owner with nothing to own a block yet, or an Addresses field,
which shares this Control. Those keep the optimistic path.

Two things found on the way:

- createEntry never checked that the entry type belongs to the field. Any
  type would save, and then Form\Controls\Matrix refused to render a block
  whose type it doesn't offer, 500ing the whole edit screen. It does now.
- Inserting from the add button's own click handler tore down the subtree
  that button lives in, mid-dispatch, and Vue then patched against DOM a
  Lion overlay inside a block had already moved. The insert is deferred a
  tick. The `:key`-forces-a-full-rebuild hack underneath it still stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
…pening

ElementSelectControl read `.length` and `.map()` straight off its value
prop. A relation nested in a Matrix block resolves its path to undefined
for a beat while that block's identity is in flux, and the throw took the
whole form down with it:

  Failed to render Form Control [...Controls\AssetSelect] with component
  [craft:element-select] at [fields.pageBuilder.entries.<uuid>.fields.asset]:
  Cannot read properties of undefined (reading 'length')

Reads go through a guarded `model`, so the field renders empty for that
frame instead. Same shape as the fix MatrixControl already carries.

Adding a block was also silent when it started from a block's "Add {type}
above": the add buttons show a spinner, but a menu item has nowhere to put
one. The status region now announces it — which is what Craft 5 did — and
the add items are disabled while a create is in flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Selection was wired to click, ctrl-click and shift-range, but nothing on
the block said it could be selected — Craft 5 puts a checkbox in the
block's actions region, next to the "⋮" menu, and that was missing.

Mirrors ElementCards: the checkbox reports through the same Selectable,
and the shift state is taken from the click that precedes the change
event, since the change itself carries no modifier keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Matrix blocks and element cards had grown the same frame twice: the card,
its select checkbox and the shift-key dance behind it, the drag handle,
the drop shadow, and where actions go. SelectableCardList owns that now,
and both compose it.

What goes *in* a card stays with the consumer, which is why this isn't
just ElementCards with another prop: the index slots in server-rendered
HTML, a Matrix field slots in a live nested form bound to the values tree.
Everything list-shaped that isn't the frame — empty states, select-all,
add buttons, grid sizing — stays put too.

Net 80 lines lighter across the two, and the checkbox Matrix blocks were
missing comes for free.

Two things worth knowing:

- The shift state is captured on the list in the capture phase rather than
  on each checkbox. `craft-checkbox` re-fires `model-value-changed` when
  Vue writes `.checked` back, so a handler that runs after the checkbox
  can see the wrong modifier state.
- `data-matrix-blocks` sits on the list itself. The legacy
  `craft-matrix-input` finds its entries with `:scope > .matrixblock`, so
  a wrapper between the two hides every block from it — which is what made
  the remove button stop working while this was being put together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Delete lives in the block's "⋮" menu, which is where Craft 5 keeps it, so
the trash icon beside it was a second way to do the same thing. Gone from
the Form control; the HTML renderer keeps its button, because on that
stack nothing listens for the menu's `craft:matrix-block-action` event and
removing it would leave no way to delete a block at all.

`craft-field` only stretches a slotted control carrying the `form-control`
class (`::slotted(.form-control)`). `craft-matrix-input` didn't reliably
have it, so a Matrix nested in a block sized to its own content — 620px
inside a 945px field, with its add buttons running past the block's edge.
The element applies the class itself now, so both renderers get it rather
than each remembering to pass it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
A disabled block looked exactly like an enabled one. It now opens
collapsed and carries a status dot at the right of its top bar, where
Craft 5 put its `.status.off`.

Disabling collapses the block and enabling expands it again — the second
half is what Craft 5's enable already did. A block that arrives disabled
starts collapsed too, so this holds on a fresh load and not just as a
side effect of the click. Either way the block can still be expanded from
its menu; this only decides where it starts.

The dot is red rather than the usual grey, set through the status
component's own custom properties (they inherit into its shadow DOM)
rather than repainting the shared `--c-status-disabled-*` tokens that
every other disabled thing in the CP reads.

Carries an in-progress change to the add buttons that couldn't be split
out of the same file: they're `craft-button` with `variant="dashed"` and
`loading` now, in place of the hand-rolled `.btn.add` markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Hiding the fields left the card's body behind: an empty padded strip under
the header, with the header's bottom border hanging under it and its
corners still square. Padding and radius live in the card's shadow DOM
with no `part` to reach them, so this belongs to the card rather than to
whoever slots content into it.

`craft-card` takes a `collapsed` attribute now. It hides body and footer
rather than dropping them, so slotted content — the hidden inputs among it
— stays in the document and keeps posting, and the header takes over the
card's full shape.

MatrixControl drives it from the same state the class does, so a collapsed
or disabled block is just its top bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Four things that had been queuing up behind each other:

A folded block now says what it is. Its own fields aren't on screen to
identify it, so the entry's UI label stands in — empty for a block type
with nothing to say, which is what getUiLabel() already returns. A title
being typed beats the server's copy, which is only as fresh as the last
save.

The header carries the entry type's icon, and the block carries its colour
as `data-color`. The CP's generated colorable rules turn that one attribute
into the whole `--c-color-*` alias set, and craft-card already paints from
those — so the card and everything in it takes the colour with no new CSS.

SelectableCardList is generic over its id type now. It was typed
`string | number`, so every slot binding in a consumer with string ids had
to narrow: seventeen `String(uid)` casts in MatrixControl, all of them
noise. None left.

Matrix field settings hide what doesn't apply: "Include Table View" and
"Entries Per Page" only mean something for the index view mode, and there
are only columns to choose once that table is switched on. Hidden rather
than dropped, so switching away and back keeps what was chosen — the
existing HasVisibility contract, with the two driving controls made
reactive so the form refreshes on change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Two things went wrong once the editor got narrow.

A slotted form control couldn't shrink. A flex item's automatic minimum
size is its content, so a control wider than its column — a Matrix field's
row of add buttons — pushed out of the field rather than reflowing. At
1200px a nested Matrix ran 161px past its block. The wrapper already had
`min-width: 0` for exactly this; the slotted control needed it too, and
now the add buttons wrap instead of forcing the width.

The details sidebar held its track long after there was room for it. It
folds down to its rail below ~880px and comes back when the space does.
Measured on the editor body rather than the viewport, because the global
sidebar and a slideout both take from the same width. `collapsed` on
craft-tabs is reflected output, not an input, so this drives selection —
and a collapse the author chose is left alone.

Carries in-progress changes to the same files that couldn't be split out:
the field label's spacing token and absolutely-positioned field actions,
and MatrixControl's block header markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
A relation field's View Mode setting shows what each mode looks like; the
Matrix one was a bare radio list, so the two settings screens read
differently for the same decision. It uses the same illustrations now —
cards, card grid, blocks and index all already existed in the CP's public
images.

The thumbnail descriptor moves to Cp::viewModeThumbnail() rather than
being copied: Matrix and BaseRelationField are siblings, so neither could
inherit it from the other, and it's the CP's own asset either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
A card decided whether to reserve its thumbnail column by asking whether
it contained anything slotted there — with a plain descendant search. Only
a direct child can ever fill a slot, so that reached straight through the
card's own body: a Matrix block holding a relation field with something
selected found the chip's thumbnail and reserved 120px it could never
fill. A block created in the UI is empty, so it looked right; the same
block after a reload didn't. Chips had the same bug for prefix and suffix.

The two Matrix renderers had drifted apart besides. MatrixControl.vue
frames each block in a craft-card by way of SelectableCardList, while
Form\Controls\Matrix::renderHtml() still emitted Craft 5's bare
titlebar/actions/fields divs, so a Matrix rendered server-side (a slideout,
copied values) looked nothing like the same field in the editor. It emits
the card frame now, with the same header layout, entry type icon, collapsed
preview, disabled status and grid spacing.

That moves the block's parts one level down, so MatrixEntry looks for them
through the card as well as directly under .matrixblock. Only the HTML path
matches the new shape — the Vue path keeps its own classes, which is what
keeps the legacy drag engine from fighting useReorderableItems over the
same nodes.

The two rules the blocks needed were scoped to MatrixControl.vue and so
reached only half of them; they move to resources/css/matrix.css.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Three things the field menu and the clipboard need had no way to reach
them.

"Expand all blocks" and "Collapse all blocks" did nothing. `modules/fields`
applies them through each block's MatrixEntry controller, and the blocks
the Vue control renders have none — so the control listens for the event
itself, scoped to its own `craft-field` so a nested Matrix stays out of its
parent's reach. Folding several blocks now takes one emit; a per-block emit
had each one overwrite the last, since the value is written back whole.

"Copy all blocks" put the clipboard's element ids, owner and site in an
object full of nulls. Craft 5's block.twig wrote all of that to the block
as data attributes and v6 wrote none of it, so `Matrix::blockData()` builds
the set and both renderers emit it. `data-id` keeps meaning the UID, which
is what the sort order and the posted value are keyed by, so the element id
rides on `data-element-id`; the copy listener prefers it and skips a block
the browser minted, which has no element to point at yet.

The server renderer dropped enabled and collapsed on save. Its form is
serialized from the DOM, so both need inputs of their own the way Craft 5
wrote them — the browser stack carries them in the Control's value instead.
A block minted by `craft-matrix-input` posts `fresh` too. MatrixEntry
updates whichever of those inputs it finds, and only creates one for the
legacy stack, which is the one renderer that doesn't write them itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Duplicate, Copy and Paste were all missing, and the items that were there
described the last save rather than the block in front of you.

Duplicate turns out to be Add with a source: `matrix/create-entry` already
took a `duplicate` id and returned the new block's form nodes. Copy hands
the block to `Craft.cp`, which owns the clipboard; Paste asks it to
duplicate what's on the clipboard onto this owner and field, then asks
`matrix/render-blocks` — which grows the same form-nodes half its sibling
already had — for the blocks to render. Both of those need the element ids
the previous commit put on the block. Pasting an entry of a type the field
doesn't offer would render once and then take the edit screen down on the
next load, so the endpoint refuses it the way `createEntry()` does.

The menu is resolved in place now rather than half of it being dropped and
rebuilt: the server still decides which items exist, in what order, and
what the user may do, and the browser fills in what only it knows. Collapse
and Expand swap as the block folds, Add and Duplicate go when the field is
full, Paste appears when the clipboard holds something that fits and says
whether it holds one block or several, and everything that acts on a
selection says so — "Delete selected blocks", not "Delete". Craft 5 did all
of this as the menu opened.

Whether the clipboard holds something usable can change while the page is
open, so `useCopiedElements()` follows it. `Craft.cp.onCopyElements()` has
no unregister, so it registers once for the page and every control reads
one ref.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Two things a collapsed block couldn't tell you.

Whether it had errors. Folding a block hides the fields its errors are
attached to, so a block that failed to save looked exactly like one that
didn't. Craft 5 put an alert icon on the block type; both renderers do that
again, the server off the entry's own errors and the browser off the live
form payload as well, so a field that goes invalid while you're typing says
so without waiting for a save.

What's in it. Craft 5 fell back to a summary built from the block's own
inputs when its entry type had no UI label; v6 showed the label or nothing,
so a type with no title and no label format folded up to a bare type name.
That summary moves to `blockPreviewParts()`, shared by all three renderers
— both stacks slot their controls into the light DOM, so one pass over the
block's fields reads them all. It's taken as the block folds, which is the
moment Craft 5 took it and the only one where the fields are still on
screen. No entity decoding on the way out: every value is read through a
DOM property, which is already text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
The add row was a plain list of dashed plus buttons. Craft 5 gave each
entry type its own icon and colour, filed them under their groups, and put
a filter box in front of them once there were more than five — none of
which reached v6, because the Control only ever knew each type's handle and
name. It takes the descriptor now, so both renderers can paint the buttons,
and the browser collapses them into a grouped, searchable menu once there
are groups to show or no room left for a row. The row's natural width is
measured while it's shown and remembered, so the two states can't chase
each other. There's a Paste button beside them too, whenever the clipboard
holds something the field can take.

Selecting cards from the keyboard was ElementCards' alone: Space and Enter
to select, arrows to walk the list, shift to extend. It belongs to the list
that owns the tabindex, so it moves to SelectableCardList and Matrix blocks
get it for free — with the guard a block needs and a card of server-
rendered HTML doesn't, that the key was pressed on the card itself. Space
inside a text field is the field's. A consumer still hears the key first
and can take it, which is all ElementCards needs for its folder cards.

Dragging one block of a selection now takes the whole selection with it,
the way Craft 5's drag-sort did. The drag engine reports one block, so the
rest are gathered as the move is applied; the drag preview still lifts the
one card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
`matrix/create-entry` persists a new block as a draft of its own, owned by
whichever element the field's form was compiled against. Edit that owner
afterwards and it becomes a provisional draft — leaving a block that is
already a draft and still primarily owned by the canonical. That is exactly
the pair the save path duplicates as a draft to keep the canonical owner's
blocks out of it, and drafting a draft throws: "Cannot create a draft from
another draft or revision." The autosave died and took the edit with it.

A block that's already a draft is already the derivative copy that step
would make, so it's skipped. Addresses and content blocks reach the same
branch by the same route and get the same guard.

The error predates Duplicate — it's in the log from before that landed —
but Duplicate is what made it easy to hit, since it adds a block to a field
whose owner is already being edited.

Two things about the block Duplicate leaves behind, while here. It arrives
with no colour, icon or menu, because the field's per-block presentation
only comes round on the next save; `matrix/create-entry` and
`matrix/render-blocks` hand it back with the block now, so a new one looks
like its neighbours straight away. And it appears wherever it happens to
land, so the page now scrolls to it and puts the cursor in its first field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
A new block lands wherever the sort order puts it — above the one you
duplicated, in the middle of a paste, at the end of a long field — and
nothing said which one was new. It gets one pass of a highlight now, in the
entry type's own colour by way of `data-color`, so the block the page has
just scrolled to is the one your eye goes to.

Painted by an overlay on `.matrixblock` rather than the block's own
background: the card paints that inside its shadow root, out of reach. At
rest the overlay is invisible, so the class is inert if it outlives the
animation.

The class goes on differently in each stack, because they own their blocks'
classes differently — `flashNewBlock()` puts it on the element for the
stacks that build their own DOM and drops it on `animationend`; the Vue
control holds the same class in reactive state, since Vue would patch a
stray one away on the next render. One class, one duration, one stylesheet.

Adding, duplicating and pasting all go through `insertBlocks()`, so all
three highlight — and a paste highlights every block it brought.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Deleting a block scrolled the page to the top.

Changing the block list re-keys `craft-matrix-input`, which tears the field
out of the document and builds it again. That is heavy-handed and it looked
worth removing — until removing it turned deleting a block into "Failed to
render Form Node [craft:field] ... Cannot read properties of null (reading
'insertBefore')". A block can hold a control that relocates its own light
DOM, a Lion overlay behind an action menu among them, and patching the list
around one of those takes the whole form down. The rebuild is what gets
past that, so it stays, with a comment saying why this time.

What it costs is the field's height for a frame, and the browser answers
that by scrolling to the top. So the position of the page — and of anything
else that scrolls around the field — is taken down before the rebuild and
put back after it. Adding a block opts out: `revealBlock` is about to
scroll somewhere better, and holding the old position would only fight it.

The real fix is for those controls to survive a patch, which would let the
key go entirely and keep focus as well as scroll. That's its own job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Adding a block took the whole form down with "Failed to render Form Control
[craft:date-time] ... Cannot read properties of undefined (reading
'timezone')", and the block only showed up after a reload.

A control inside a block the server has just minted renders a beat before
its values reach the tree — the form describing it and the values filling
it arrive on the same response but land one emit apart. Every part of a
date is dereferenced on the way to the input, so there was nothing to
absorb that beat, and a throw during render is what "Failed to render Form
Control" is: the renderer swaps the control for the error.

Same guard the Matrix and element-select controls already carry: read a
`model` that stands in an empty value, and let the real one arrive.

Nothing is wrong with what the server sends — a date field with no value
still ships `date`, `time` and the system timezone — so once the values
land the input fills in. Verified on a throwaway entry: a Kitchen Sink
block adds without an error, and its date reads America/Chicago after a
reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Three controls had each grown their own guard for the same thing, and
seven more had none. `controlValue()` is that guard, once: a computed that
reads the control's value and stands in a frozen empty one until the real
one arrives.

Why it's needed is worth saying in one place rather than three. A control
renders as soon as the form describing it does, and inside a nested form
that can be one emit ahead of the values filling it — a block the server
has just minted, a repeater whose identity the server has just rewritten. A
control that reaches into its value throws in that gap, and a throw during
render is what "Failed to render Form Control" is: the renderer swaps the
control for the error, and the field is gone until the page is reloaded.

Matrix, element-select and date-time move onto it. Table, address, link,
condition-builder, field-layout-designer and grouped-entry-type-manager get
it for the first time — table called `Object.entries()` on its value and
address indexed into it, both of which throw on nothing. Content block
tests loosely instead: null already meant "no content", and undefined means
the same thing to it, so there's nothing to stand in.

Controls whose value is a scalar are left alone. They coerce `undefined`
already, and `value: unknown` says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
`controlValue()` was opt-in: it only protected controls whose authors
remembered it, and it hard-coded a second copy of what "empty" means beside
the one the server already had. Both problems go away by moving the answer
into the payload.

`Control::emptyValue()` is what a control reads when its value is missing.
`controlValueAt()` applies it at the two places a value reaches a control —
`FieldNode` and `ActionNode` — so no control has to guard itself, and none
can forget. Matrix, element-select, date-time, table, address, link,
condition-builder, field-layout-designer and grouped-entry-type-manager all
go back to a plain non-optional `value`.

Only controls that reach into a shape declare one. Choice and permission
tree don't: both already ask `Array.isArray()` first, and a single-select
Choice's empty is a scalar, not `[]` — the settings-payload fixture caught
that, which is what fixtures are for.

Substituting on undefined rather than null is the distinction that makes
this safe for content blocks: null is a value a control chose, absent is
the payload not having caught up.

`emptyValue` stays out of `FormControlPayload` and is typed at
`controlValueAt()` instead. `FormValue` is recursive and the payload type
already sits near TypeScript's instantiation limit; another branch of it
tips several inference sites over. Two `shallowRef`s and one narrower
annotation would have papered over that, and the next field to grow a
recursive type would have re-broken it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
The status indicator's label and the required marker each hand-rolled a
span with the visually-hidden class. There's a component for that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
z-1000 put the header above overlays that need to sit over it; z-10 is
enough to clear the form beneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjeigCjRgjTJpnhbvyrxv8
Two conflicts, both where 6.x moved something this branch had been
building on.

`ElementEditScreen.vue` — 6.x extracts the details column into
`ElementDetailsTabs`, which now owns the `craft-tabs` this branch had put a
ref on to fold the column away below 880px. Took 6.x's structure whole and
dropped the auto-collapse rather than wire a ref through the new component
inside a merge; it comes back in the next commit, in the component that
owns the tabs.

`ElementSelectControl.test.ts` — both sides added a test in the same spot.
Kept both.

One semantic conflict with no markers: 6.x gives a single relation a scalar
value, so `ElementSelect`'s `emptyValue()` of `[]` was wrong for it — and
the control normalizes any shape itself now, so it doesn't want one at all.
Dropped it, and made the control's null check loose so an absent value
reads the same as an empty one. Same call as Choice and permission tree.
6.x moved the details tabs into ElementDetailsTabs, which took the
`craft-tabs` this behaviour was driving with it. It moves too, into the
component that now owns those tabs.

The width still comes from the editor body — the global sidebar and a
slideout both take from the same space, so the viewport is the wrong thing
to measure — and ElementEditScreen owns that element, so it measures and
passes the number down.
@github-actions

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/uiopen Storybook

Changed components:

resources/jsopen Storybook

No changed components detected in this Storybook.

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