Skip to content

[6.x] Fire ElementCardPartsResolving for every element card - #20335

Merged
brandonkelly merged 10 commits into
6.xfrom
bugfix/element-card-html-resolving
Nov 4, 2026
Merged

brandonkelly merged 10 commits into
6.xfrom
bugfix/element-card-html-resolving

Conversation

@i-just

@i-just i-just commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Description

ElementCardHtmlResolving only fired from ElementHtml::elementCardHtml(). Cards rendered by the card component's slots (Matrix fields, relation fields, element indexes, CKEditor's nested entries) come from elementCardData(), which never fired it. So neither Craft 6 listeners nor Craft 5 Cp::EVENT_DEFINE_ELEMENT_CARD_HTML handlers (through the Yii adapter) could change those cards.

Both renderers now build the same ElementCardParts in one place and fire one parts-only event with them:

elementCardHtml()  ─┐                                          ┌─ elementCardPartsHtml(parts)
                    ├─ elementCardParts() → ElementCardParts ──┤
elementCardData()  ─┘   fires ElementCardPartsResolving        └─ card data array
                        (element, context, parts — no html)

Yii adapter (only when EVENT_DEFINE_ELEMENT_CARD_HTML has handlers):
  parts → HTML → Craft 5 handlers → changed? → split back into parts
  • ElementCardHtmlResolving is renamed to ElementCardPartsResolving, with $element, $context and $parts, and no $html. Listeners edit $event->parts, and the change applies wherever the card is shown. There's one way to change a card, so listeners can't silently override each other.
  • Listeners get the same parts from both renderers. The selection checkbox is added when the card is rendered rather than stored in actionsHtml, and withThumb means the same thing in both.
  • ElementCardParts holds attributes, labelHtml, actionsHtml, contentHtml, footerHtml, thumbHtml and thumbAlignment.
  • The Yii adapter owns Craft 5 compatibility. It renders the parts for Cp::EVENT_DEFINE_ELEMENT_CARD_HTML handlers, and only when a handler changes the HTML does it split it back into parts. HTML that isn't a <craft-card> becomes the card's content, keeping the original attributes and footer unless the override already contains it. Markup outside the card root goes in the footer.
  • ElementCards.vue now passes cardAttributes.class through to <craft-card>, so classes listeners add show up on Matrix, relation and index cards.

Breaking: ElementCardHtmlResolving is renamed to ElementCardPartsResolving, and its $html property is replaced by $parts.

Notes

Related issues

🤖 Generated with Claude Code

i-just and others added 2 commits November 4, 2026 13:14
Cards rendered by the card component's slots, such as Matrix, relation
field, element index, and CKEditor cards, come from elementCardData(),
which never fired the event, so neither its listeners nor Craft 5
EVENT_DEFINE_ELEMENT_CARD_HTML handlers could change them.

Both card renderers now build an ElementCardParts and fire the same
event with it, so a listener that edits the parts changes the card
wherever it's shown. Setting the event's html still replaces the whole
card; for slot-rendered cards it's split back into parts. The Yii
adapter passes Craft 5 handlers the card's HTML, and only takes over the
card when a handler changed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/ui — open Storybook

No changed components detected in this Storybook.

resources/js — open Storybook

Changed components:

@i-just

i-just commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@riasvdv, @brandonkelly, any thoughts on this one?
I’m not 100% sure if it’s the best way to go, but we need the ElementCardHtmlResolving event firing for fields like Matrix (like it did on v5), and ideally we shouldn’t require devs to know if the card was rendered via ElementHtml::elementCardHtml() or ElementHtml::elementCardData()

…pter

ElementCardHtmlResolving is renamed to ElementCardPartsResolving and only
carries the card's parts. Both card renderers build the parts and fire the
event in one place, so listeners see the same parts everywhere.

The Yii adapter renders the parts for Craft 5 EVENT_DEFINE_ELEMENT_CARD_HTML
handlers and splits changed HTML back into parts, keeping the original footer
when the result isn't a card.

ElementCards.vue now passes server card classes through to <craft-card>.

Amp-Thread-ID: https://ampcode.com/threads/T-06e10032-984e-4027-9c79-c92ca17a25e3
Co-authored-by: Amp <amp@ampcode.com>

Copy link
Copy Markdown
Member

@i-just, here's a counter-proposal on top of this branch, for comparison: card-parts-event (c73c4ce)

It keeps the main idea: both card renderers build the same parts and fire one event. What changes is where $html lives.

  • Core only has parts. ElementCardHtmlResolving is renamed to ElementCardPartsResolving, with $element, $context and $parts and no $html. That means there's only one way to change a card, so a listener that sets html can't silently override another listener's changes to parts. Craft 6 isn't out yet, so the rename only needs a changelog note.
  • The Yii adapter owns the HTML round-trip. It renders the parts for Cp::EVENT_DEFINE_ELEMENT_CARD_HTML handlers, and only when a handler changes the HTML does it split the result back into parts. The Dom\HTMLDocument parser moves out of ElementHtml into the legacy Cp helper. If a handler returns something that isn't a card, the original footer is kept, since that's where the field's hidden input is.
  • Listeners get the same parts from both renderers. The selection checkbox is added at render time instead of being stored in actionsHtml, and withThumb means the same thing in both renderers. The test now compares all of the parts, not just the header and content.
  • ElementCards.vue passes class through. Before this, classes in cardAttributes were dropped, so a listener adding a class had no effect on Matrix, relation or index cards. The Storybook render is pixel-identical before and after.
  • ElementCardParts is now a plain, non-final class without Arrayable, and headerHtml is renamed to labelHtml, since it holds what elementCardLabelHtml() returns.

One tradeoff: Craft 5 handlers' HTML is now always parsed back into parts, including for elementCardHtml(). So a handler that wraps the card in another element will end up with that wrapper as the card's content.

A handler that wraps the card already includes its footer, so the original
footer is only restored when the override lacks it. Markup a handler adds
next to the card root now goes in the footer instead of being dropped.

Amp-Thread-ID: https://ampcode.com/threads/T-06e10032-984e-4027-9c79-c92ca17a25e3
@i-just

i-just commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@brandonkelly, I like it! It keeps the core simpler.
I think the tradeoff is fine, and switching to the new event (if/when needed) shouldn’t be hard.

[6.x] Parts-only card event, with Craft 5 card HTML handled by the Yii adapter
@brandonkelly brandonkelly changed the title [6.x] Fire ElementCardHtmlResolving for every element card [6.x] Fire ElementCardPartsResolving for every element card Nov 4, 2026
ElementCards passes server card classes through to <craft-card>, which put
the structural element class on the card as well as its <li>. Quick-edit
finds the row with closest('.element'), so it picked the non-focusable card
and couldn't restore focus after the editor closed. The element and error
classes now stay on the list item only.

Amp-Thread-ID: https://ampcode.com/threads/T-06e10032-984e-4027-9c79-c92ca17a25e3
Co-authored-by: Amp <amp@ampcode.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved card-rendering issues can block Matrix editor access, lose listener-added CSS classes, and omit listener-added actions from nested-element cards.

3 open issues
What changed in this PR

This PR unifies element-card customization across HTML and component-slot rendering through a shared parts event, with Craft 5 HTML compatibility handled by the Yii adapter.

Changes:

  • Replaces ElementCardHtmlResolving with ElementCardPartsResolving and shared card-part construction.
  • Converts legacy HTML overrides back into card parts.
  • Forwards server-provided card classes in Vue and adds regression coverage.
File Description
yii2-adapter/​tests-laravel/​Helpers/​CpElementCardHtmlTest.php Adds legacy-handler and HTML round-trip coverage.
yii2-adapter/​legacy/​helpers/​Cp.php Bridges the parts event to legacy HTML handlers.
yii2-adapter/​legacy/​events/​DefineElementHtmlEvent.php Updates the deprecation comment to name the new event.
tests/​Feature/​Cp/​ElementHtmlTest.php Tests shared event behavior across both renderers.
src/​Cp/​Html/​ElementHtml.php Centralizes card-part construction and rendering.
src/​Cp/​Events/​ElementCardPartsResolving.php Introduces the mutable parts event.
src/​Cp/​Events/​ElementCardHtmlResolving.php Removes the previous HTML event.
src/​Cp/​Data/​ElementCardParts.php Defines the shared card-parts container.
resources/​js/​modules/​elements/​components/​ElementCards.vue Passes server classes to cards while retaining wrapper classes.
resources/​js/​modules/​elements/​components/​ElementCards.test.ts Tests card and list-item class placement.
CHANGELOG.md Documents the API change and expanded event coverage.

Review effort: Balanced

Comment thread src/Cp/Html/ElementHtml.php Resolved
Comment thread yii2-adapter/legacy/helpers/Cp.php Resolved
brandonkelly and others added 2 commits November 4, 2026 19:54
When a Craft 5 card HTML handler changed a card, the reparsed attributes
replaced the card's structured data group with flat data-* strings, so
Matrix lost data.editable and hid the card's Edit button. Only the
attributes a handler added, changed, or removed are now applied to the
original attributes.

ElementCards also reads server class lists through attrs(), so a PHP list
with gaps in its keys no longer renders as index classes.

Amp-Thread-ID: https://ampcode.com/threads/T-06e10032-984e-4027-9c79-c92ca17a25e3
Co-authored-by: Amp <amp@ampcode.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Craft 5 handler overrides can duplicate hidden inputs when markup is reserialized and discard buttons appended beside the existing card actions, causing duplicate submissions and lost controls.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

Medium severity Match footer inputs structurally to prevent duplicate submissions

yii2-adapter/​legacy/​helpers/​Cp.php:975

If a Craft 5 handler wraps the card and reserializes its hidden input, even just reordering attributes, this string comparison misses the existing input and appends the original footer again. A field such as myFieldName[] then submits the same element twice. Match existing footer inputs structurally in the parsed override rather than requiring identical HTML.

Medium severity Preserve sibling nodes when unwrapping card actions

yii2-adapter/​legacy/​helpers/​Cp.php:988

A Craft 5 handler that appends a button to .card-actions-container, beside the existing .card-actions div, loses that button here: querySelector() finds the existing div, so only its contents survive and its siblings are discarded. Previously the handler’s HTML was returned intact, so this also regresses elementCardHtml(). Please unwrap .card-actions while preserving the container’s other child nodes.

3 resolved since last review

Review effort: Balanced

The original footer is now dropped when the override already has its inputs,
matched by name and value rather than identical HTML. Buttons a handler adds
beside .card-actions are kept when the actions are unwrapped.

Amp-Thread-ID: https://ampcode.com/threads/T-06e10032-984e-4027-9c79-c92ca17a25e3
Co-authored-by: Amp <amp@ampcode.com>

Copy link
Copy Markdown
Member

Re: Copilot's latest review: I agree with both findings, and both are fixed in 8dc2a68.

  • Matching footer inputs structurally. When an override isn't a card, the original footer is now dropped only if every input in it appears in the override with the same name and value. Re-serialized inputs, such as ones with their attributes reordered, no longer get the hidden input added a second time. The wrapper test now also re-serializes the input and checks there's exactly one myFieldName[].
  • Keeping siblings of the card actions. .card-actions is unwrapped and its siblings in .card-actions-container are kept, so a button a handler adds next to it survives. There's a new adapter test for this.

Both tests fail without the fix. All 8 adapter card tests pass, and the adapter's PHPStan reports no errors.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Craft 5 overrides can discard existing footer content and misclassify notices as thumbnails, hiding those notices on cover-image cards.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity Keep non-thumbnail card markup in contentHtml

yii2-adapter/​legacy/​helpers/​Cp.php:1161

A Craft 5 handler’s notice inserted alongside .card-content is moved into thumbHtml because every sibling is treated as a thumbnail. ElementCards.vue puts that fragment in the thumbnail slot, which CraftCard.render() hides on cover-image cards, so the notice disappears. Identify the actual thumbnail and retain other body markup in contentHtml.

Review effort: Balanced

Comment thread yii2-adapter/legacy/helpers/Cp.php Resolved
Only the footer inputs an override already has are dropped now, so other
footer content survives. Within .card-main, only .thumb elements become the
thumbnail; other markup a handler adds beside the content stays in it.

Amp-Thread-ID: https://ampcode.com/threads/T-06e10032-984e-4027-9c79-c92ca17a25e3
Co-authored-by: Amp <amp@ampcode.com>

Copy link
Copy Markdown
Member

Re: the previously missed issue in Copilot's latest review ("Keep non-thumbnail card markup in contentHtml"): agreed, fixed in e01c07c. Within .card-main, only .thumb elements, which is what getThumbHtml() renders, now become the thumbnail. Other markup a handler adds before or after .card-content stays in contentHtml in the same position, so cover-image cards no longer hide it. A new adapter test covers this and fails without the fix.

@brandonkelly
brandonkelly merged commit 9282b08 into 6.x Nov 4, 2026
20 checks passed
@brandonkelly
brandonkelly deleted the bugfix/element-card-html-resolving branch November 4, 2026 22:39
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.

3 participants