#19240·Sylius

[ShopBundle] Promotion details tooltip never renders — leftover Semantic UI `data-html` attribute on an empty `<i>`

Author: DarkorzerarkoCreated Sep 17, 2026Updated Sep 17, 2026
LabelsPotential Bug

Sylius version(s) affected

All 2.X

Description

src/Sylius/Bundle/ShopBundle/templates/shared/order/show/summary/table/body/unit_price.html.twig (lines 11-16) builds the per-unit promotion breakdown into a data-html attribute:

twig
<span {{ sylius_test_html_attribute('product-unit-price', item.productName) }}>{{ money.convertAndFormat(item.discountedUnitPrice) }}
    {% if item.unitPrice != item.discountedUnitPrice %}
        <i data-html="{% for promotion in unit_promotions %}<div>{{ promotion.label }}: {{ money.convertAndFormat(promotion.amount) }}</div>{% endfor %}">
        </i>
    {% endif %}
</span>

data-html is an attribute of the Semantic UI popup module, which Sylius 1.x used. Semantic UI is gone from 2.x in favour of Bootstrap 5 (bootstrap: ^5.3.3 in src/Sylius/Bundle/ShopBundle/package.json); nothing under src/Sylius references it anymore. The attribute is therefore inert — no code in 2.x reads it.

The 1.14 version of the same markup shows what was lost in the port (.../Resources/views/Common/Order/Table/_item.html.twig):

twig
<i id="item-promotion-details" class="question circle icon unit-promotions popup-js"
   data-html="{% for promotion in unitPromotions %}<div>{{ promotion.label }}: {{ money.convertAndFormat(promotion.amount) }}</div>{% endfor %}">
</i>

paired with Resources/private/js/app.js:26:

javascript
$('.popup-js').popup();

The 2.x port kept data-html but dropped both the Semantic UI icon classes (question circle icon) and the popup-js initialisation hook. Two consequences:

  1. The <i> now carries no class and no content, so it renders 0px wide. There is nothing for the customer to hover or focus — the affordance is invisible.
  2. Even if it were visible, nothing would open. Bootstrap expects data-bs-toggle="tooltip" together with data-bs-title (and data-bs-html="true" for markup); src/Sylius/Bundle/ShopBundle/Resources/assets/scripts/bootstrap.js only initialises elements matching [data-bs-toggle="tooltip"].

Net effect: the promotion breakdown is serialised into the DOM on every discounted order line and is never shown to the customer. The order summary displays the original price next to the discounted price with no indication of which promotions produced the difference. The original price is not even struck through — its <span> carries no class, so the two amounts read as two plain prices side by side.

Bootstrap tooltips do work in the shop otherwise: the "More" button of shop grids (src/Sylius/Bundle/ShopBundle/templates/shared/macro/grid/table.html.twig, line 48) uses data-bs-toggle="tooltip" and is picked up by the initialiser above. The promotion details are simply not wired to it.

Same leftover in two unused sibling templates

The identical markup also exists in two more shop templates:

  • src/Sylius/Bundle/ShopBundle/templates/shared/order/table/item.html.twig:16
  • src/Sylius/Bundle/ShopBundle/templates/shared/order/table/shipping.html.twig:17

Neither is referenced anywhere in 2.x — no template includes them, no Twig hook registers them and no PHP code renders them — so they appear to be dead leftovers of the port rather than a second place where customers see the problem.

shipping.html.twig carries sylius_test_html_attribute('shipping-promotion-details'), but that is not the element Behat relies on. The same test attribute is rendered by the active src/Sylius/Bundle/ShopBundle/templates/shared/order/show/summary/table_summary/shipping/value.html.twig (line 19), and that is what the [data-test-shipping-promotion-details] selector in src/Sylius/Behat/Page/Shop/Checkout/CompletePage.php:273 matches for the CheckoutCompleteContext steps. The two templates could therefore be removed without touching the Behat pages, or wired up and fixed together with the main template.

How to reproduce

  1. Install Sylius Standard 2.2 with the default fixtures and build the assets (yarn build).
  2. In the admin, go to Marketing → Cart promotions and create a promotion with an Item fixed discount or Item percentage discount action, so that item.unitPrice != item.discountedUnitPrice. The default fixtures do not contain such a promotion. A catalog promotion does not trigger the bug: it lowers unitPrice itself (OrderPricesRecalculator), while discountedUnitPrice only differs from unitPrice by ORDER_UNIT_PROMOTION_ADJUSTMENT adjustments (OrderItem::getDiscountedUnitPrice()).
  3. As a registered customer (e.g. [email protected] / sylius from the fixtures), add a product the promotion applies to to the cart and go through checkout up to the last step, Complete. Look at the unit price in the order summary.
  4. Place the order and open the shop order page for it: My account → Orders → (order).

Expected: on both pages, an info affordance next to the discounted unit price that reveals the applied promotions and their amounts. On the checkout Complete step this is the customer's last chance to see which promotions produced the discount before placing the order. Both pages render the same cell: checkout/complete/content/form/summary.html.twig and account/order/show/content/main/summary.html.twig both render the summary under the sylius_shop.shared.order.show hook prefix, so the unit price comes from unit_price.html.twig in both places.

Actual: on both pages only the old and new price are shown; there is no icon and no tooltip. Inspecting the DOM shows the data sitting unused in the markup, here for an order line with the three promotions used in the screenshots below:

xml
<i data-html="<div>Christmas sale – 10% off all clothing and accessories until 24 December: -$2.00</div><div>Loyalty programme discount for customers with five or more previous orders: -$1.50</div><div>Bundle offer when buying two or more T-shirts from the summer collection: -$1.00</div>">
</i>

Hovering the unit price, including the spot right after it where the empty <i> sits, shows nothing. The <i> is 0px wide and cannot receive keyboard focus. This is how the order summary table looks on both the checkout Complete step and the account order page, here with the Apollo T-Shirt line of $86.67 discounted to $82.17 by the three unit promotions:

Order summary table: the Apollo T-Shirt line shows $86.67 and $82.17 side by side, with no icon and no promotion details

Image

Possible Solution

The straightforward port is an icon next to the discounted price that opens a Bootstrap overlay. Both overlays Bootstrap offers work, but both are narrow by default. Promotion names are free text, so I tried both on an order line with three promotions whose names are full sentences.

A. Tooltip. Tooltips are already initialised by scripts/bootstrap.js, so this needs no new JS:

twig
<i class="icon icon-sm"
   data-bs-toggle="tooltip"
   data-bs-html="true"
   data-bs-title="{% for promotion in unit_promotions %}<div>{{ promotion.label }}: {{ money.convertAndFormat(promotion.amount) }}</div>{% endfor %}"
>{{ ux_icon('tabler:help-circle') }}</i>

Bootstrap 5.3 defaults --bs-tooltip-max-width to 200px, and the shop stylesheets do not override it — there are no tooltip or popover rules anywhere under src/Sylius/Bundle/ShopBundle/Resources/assets/styles. The three promotions wrap into eleven lines (239px tall), the last amount ends up alone on a line, and the tooltip covers the column headers:

Image

B. Popover. scripts/bootstrap.js does not initialise popovers, so this needs an initialiser next to the existing tooltip one (window.bootstrap is already exposed):

javascript
// Initialize popovers
(() => {
    document.querySelectorAll('[data-bs-toggle="popover"]').forEach((popoverTriggerEl) => {
        new bootstrap.Popover(popoverTriggerEl);
    });
})();
twig
<i class="icon icon-sm"
   data-bs-toggle="popover"
   data-bs-trigger="hover focus"
   data-bs-html="true"
   data-bs-title="{{ 'sylius.ui.applied_promotions'|trans }}"
   data-bs-content="{% for promotion in unit_promotions %}<div>{{ promotion.label }}: {{ money.convertAndFormat(promotion.amount) }}</div>{% endfor %}"
>{{ ux_icon('tabler:help-circle') }}</i>

sylius.ui.applied_promotions would be a new translation key; the existing sylius.ui.promotions reads "Cart promotions". The screenshots use the text "Applied promotions".

--bs-popover-max-width defaults to 276px, so it is narrow too. The same three promotions wrap into nine lines (259px tall including the header):

Image

The width cannot be fixed from inside the content. The limit is a max-width on the popover box itself, so text-nowrap makes the text spill out of the box (559px of text in the 276px popover). A min-width wrapper would need a style attribute, which the default sanitizer strips, along with <table> markup. What does work is raising the variable through a custom class:

twig
data-bs-custom-class="promotion-popover"
scss
.promotion-popover {
    --bs-popover-max-width: 480px;
}
Image

Tooltips accept data-bs-custom-class as well, so A could be widened the same way. Still, I would suggest the widened popover over a tooltip: it is a light, bordered card like the rest of the shop instead of a black overlay, the header says what the list is, and the amounts stay readable. The cost is a new initialiser plus one style rule. A fixed width is only a better guess, not a guarantee — at 480px each of these names still takes two lines.

Whichever overlay is chosen, <i> with no text content is not focusable and exposes nothing to assistive technology, so the breakdown would stay unreachable for keyboard and screen reader users. The trigger should be a <button type="button"> with an aria-label, like the shop's existing icon-only buttons, e.g. the cart button in shared/components/header/cart.html.twig (line 8).

Is there a simpler way the team would prefer? An overlay may be more than this needs. For example, the breakdown could be rendered inline underneath the discounted price as a small muted list. The order summary already does exactly this for shipping: shared/order/show/summary/table_summary/shipping/value.html.twig strikes through the original shipping cost and lists the applied shipping promotions underneath in small muted text. That needs no JS and has no width limit, and screen readers can reach it without extra markup, at the cost of a taller summary table. I am open to other ideas.

I am happy to open a PR for whichever option the team prefers.

Additional Context

  • Affected templates:
    • src/Sylius/Bundle/ShopBundle/templates/shared/order/show/summary/table/body/unit_price.html.twig (lines 12-15)
    • src/Sylius/Bundle/ShopBundle/templates/shared/order/table/item.html.twig (line 16, unreferenced)
    • src/Sylius/Bundle/ShopBundle/templates/shared/order/table/shipping.html.twig (line 17, unreferenced)
  • Tooltip initialisation: src/Sylius/Bundle/ShopBundle/Resources/assets/scripts/bootstrap.js (tooltips and dropdowns only; window.bootstrap is exposed, so the Popover class is available but never instantiated)
  • Existing shop tooltip: src/Sylius/Bundle/ShopBundle/templates/shared/macro/grid/table.html.twig (line 48)
  • Shipping promotions already listed inline: src/Sylius/Bundle/ShopBundle/templates/shared/order/show/summary/table_summary/shipping/value.html.twig
  • Bootstrap version: ^5.3.3, declared in src/Sylius/Bundle/ShopBundle/package.json
  • Icon convention in the shop: ux_icon('tabler:...'), e.g. shared/flashes.html.twig, shared/buttons.html.twig
  • The shop does not use Turbo, so a one-shot initialiser at page load is sufficient — no re-initialisation concerns
  • Reproduced on a clean Sylius Standard install (sylius/sylius 2.2.9, default fixtures, USD channel), in Chrome 153; the overlay sizes above were measured there