Commit f9f311f5b9d for woocommerce
commit f9f311f5b9d7856bb0006f65caa829ef4e0b5c94
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Thu May 7 17:11:36 2026 +0300
Add per-item review form row and submit gate (#64526)
* Add accessible star-rating control for Review Order page
Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
visually replaced by SVG stars. Progressive enhancement adds:
- Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
moves focus and dispatches `change`, so any future submission listener
reacts naturally.
- Dynamic caption underneath the stars, populated from the focused/checked
radio's `data-label`. Defaults match the existing WC product-review
labels (Very poor / Not that bad / Average / Good / Perfect) and are
filterable via `woocommerce_review_order_rating_labels`.
- High-contrast focus ring on the wrapping label whenever its input is
`:focus-visible`.
- Hover preview lights stars left of the cursor; selection persists when
the cursor leaves.
Without JavaScript the underlying radio group still works as a plain
form field, so the page stays functional.
Files:
- `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
Builds the markup and exposes `get_labels()` (filter-aware, with safe
fallbacks for buggy filters that drop slot keys).
- `templates/order/star-rating.php` — theme-overridable partial.
- `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
- `client/legacy/css/order-review.scss` — visual states.
- `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
enqueued only when the review-order endpoint is actually rendering.
- `templates/order/customer-review-order.php` — temporary preview render
per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
swaps this preview for the real per-item form row.
Tests:
- `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
override, filter fallback for missing keys.
Closes #64308 (WOOPLUG-6594).
* Add changefile(s) from automation for the following project(s): woocommerce
* Remove duplicate changelog file (workflow generates the canonical one)
* Address Copilot feedback on star-rating control PR
- Route enqueue URLs through woocommerce_get_asset_url filter so
CDN/asset-rewrite setups continue to work.
- Add screen-reader-only 'Required' text alongside the visual asterisk
on the rating label.
- Correct the JS file header comment: only Arrow keys + Home/End are
handled, not Space/Enter.
* Fix StarRating docblock to use flat @param syntax
The PHPDoc shape syntax (array{...}) trips the WC PHPCS sniff, which
expects a single @param line per parameter. Reverted to plain
@param array with the key descriptions in the body.
* Drop the planning comment + minor tidy on star-rating styles
* Load the RTL stylesheet variant on RTL sites
wp_style_add_data( 'wc-order-review', 'rtl', 'replace' ) tells the
classic-assets pipeline to swap order-review.css for order-review-rtl.css
on right-to-left locales.
* Trim defensive fallbacks and dedupe asset enqueue helpers
- StarRating::get_labels(): replace the manual 1-5 fallback loop and
reorder block with array_replace + array_intersect_key.
- StarRating::render(): drop the unused $selected pre-cast; read it
inline only when it's used.
- Endpoint::enqueue_assets(): factor a local closure for the
woocommerce_get_asset_url filter so we don't repeat the same call
for the style and the script.
- order/star-rating.php template: drop the data-caption-id attribute
that duplicated aria-describedby.
- order-review.js: read the caption id from aria-describedby and drop
the empty-input early-out (init() already filters by class).
No behaviour change. All six StarRating tests still pass.
* Address review feedback on the star-rating control
- Add @since 10.8.0 annotations on StarRating::render() and
StarRating::get_labels() docblocks (per ayushpahwa).
- Bound the `selected` argument: values outside 0-5 fall back to
no-selection rather than passing an unrenderable value to the
template (per ayushpahwa).
- Cover the bound with a regression test.
* Address remaining review feedback on the star-rating control
- CSS: drop every `:has()` selector. The template now renders the
inputs in reverse DOM order (5..1) and uses `flex-direction:
row-reverse`; selected/hover/focus states are driven by the `~`
sibling combinator and `:focus-visible + label`. Caption gets
`order: -1` so it stays on the visual right.
- JS: keyboard navigation flips so ArrowRight/Down still moves
visually right (DOM-previous) and Home/End map to the visual
leftmost/rightmost stars.
- Template: pre-compute `$initial_caption` so the caption element no
longer mixes selection logic, escaping, and output in a single
expression.
- Endpoint: enqueue the JS with `strategy => defer` (in addition to
`in_footer => true`) since it only attaches DOM-ready listeners.
* Add accessible star-rating control for Review Order page
Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
visually replaced by SVG stars. Progressive enhancement adds:
- Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
moves focus and dispatches `change`, so any future submission listener
reacts naturally.
- Dynamic caption underneath the stars, populated from the focused/checked
radio's `data-label`. Defaults match the existing WC product-review
labels (Very poor / Not that bad / Average / Good / Perfect) and are
filterable via `woocommerce_review_order_rating_labels`.
- High-contrast focus ring on the wrapping label whenever its input is
`:focus-visible`.
- Hover preview lights stars left of the cursor; selection persists when
the cursor leaves.
Without JavaScript the underlying radio group still works as a plain
form field, so the page stays functional.
Files:
- `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
Builds the markup and exposes `get_labels()` (filter-aware, with safe
fallbacks for buggy filters that drop slot keys).
- `templates/order/star-rating.php` — theme-overridable partial.
- `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
- `client/legacy/css/order-review.scss` — visual states.
- `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
enqueued only when the review-order endpoint is actually rendering.
- `templates/order/customer-review-order.php` — temporary preview render
per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
swaps this preview for the real per-item form row.
Tests:
- `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
override, filter fallback for missing keys.
Closes #64308 (WOOPLUG-6594).
* Add per-item review form row and submit gate
Replaces the temporary preview render from #64525 with the real per-item
form that the Customer Review Request page submits to. Submission handler
itself lands in M4 (WOOPLUG-6596).
`templates/order/customer-review-order-row.php` (new, theme-overridable):
- Linked product title that opens the product page in a new tab.
- 120×120 thumbnail.
- Hidden `product_id` and `order_item_id` so the row is identifiable
on submit.
- The accessible star-rating control from #64525, indexed by `row_index`
for stable POST data.
- Textarea labelled "Your review" with the i18n placeholder
"Share your experience with this product...".
- `do_action( 'woocommerce_review_order_form_fields', $item, $product,
$order, $row_index )` after the textarea so extensions can inject
fields without overriding the whole template.
`templates/order/customer-review-order.php`:
- Wraps the item list in `<form class="woocommerce-review-order__form"
method="post" action="">` with a hidden `order_id`, hidden `key`
(echoed back from the URL the Endpoint already validated), and the
`woocommerce_submit_order_reviews` nonce.
- "Submit reviews" button rendered disabled by default. The JS module
enables it as soon as any row has a rating selected.
`client/legacy/js/frontend/order-review.js` gains `initSubmitGate(form)`,
which listens for `change` events on `.woocommerce-star-rating__input`
inside the form and toggles the button's `disabled` state.
- StarRating's docblock simplified to plain `@param array` so the WC
PHPCS sniffs don't misparse the structured array shorthand.
Closes #64309 (WOOPLUG-6595).
* Add changefile(s) from automation for the following project(s): woocommerce
* Drop M-numbered milestone references from per-item row templates
* Strip non-layout styling from per-item row; let theme drive typography
* Stack the per-item row vertically on viewports under 600px
Folded in from the (now-closed) design-tokens-responsive PR; the only
contribution that survived the simplification was the 12-line media
query, which fits naturally with the per-item layout introduced here.
* Add changefile(s) from automation for the following project(s): woocommerce
* Wire customer-review-order.php to the per-item row partial
The 6595 changes had been dropped during cascading rebases: the form
wrapper, hidden inputs, foreach loop calling
wc_get_template('order/customer-review-order-row.php', ...), and the
submit button were all missing. Restored so each item actually renders
the row partial and the submit gate's selectors exist in the DOM.
* Strengthen the new-tab product link
- rel='noopener noreferrer' (was just 'noopener') to also prevent
referrer leakage in some browsers/configurations.
- Add a .screen-reader-text 'opens in a new tab' suffix so assistive
tech users are warned the link spawns a new window.
* Address per-item-row review feedback
- Collapse the .woocommerce-review-order__item top border to a single
declaration; the previous form layered border-top-color over a
currentColor border, which was redundant.
- Log a console.warn from initSubmitGate when the submit button is
missing so theme overrides that drop it surface the cause.
* Wrap the long console.warn message to satisfy eslint max-len
* Address CodeRabbit feedback on the per-item form PR
- order-review.js: expose syncSubmit on the form element as
form.syncReviewOrderSubmitGate so external code (the AJAX submission
handler in #64527) can re-evaluate the disabled state after async
state changes. Matches the PR description.
- customer-review-order.php: pre-filter $items to renderable rows
(line items with a still-existing product) and gate the <form>
render on the filtered list, so an order with all-deleted products
doesn't show a form whose submit button can't do anything useful.
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64526-wooplug-6595-per-item-review-form-row b/plugins/woocommerce/changelog/64526-wooplug-6595-per-item-review-form-row
new file mode 100644
index 00000000000..903aacf48dc
--- /dev/null
+++ b/plugins/woocommerce/changelog/64526-wooplug-6595-per-item-review-form-row
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the per-item review form row on the Review Order page: linked product title, thumbnail, accessible star rating, review textarea, and an extensions hook (`woocommerce_review_order_form_fields`). Submit button is gated on at least one rating being selected. Narrow-viewport layout stacks the row vertically below 600px.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/wooplug-6594-star-rating-control b/plugins/woocommerce/changelog/wooplug-6594-star-rating-control
new file mode 100644
index 00000000000..7477460ff57
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6594-star-rating-control
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the accessible 5-star rating control used by the Review Order page. Native radio inputs with keyboard navigation (arrows, Home, End), dynamic caption, and visible focus ring; filterable labels via `woocommerce_review_order_rating_labels`.
diff --git a/plugins/woocommerce/client/legacy/css/order-review.scss b/plugins/woocommerce/client/legacy/css/order-review.scss
index 06ea797fe44..da3954e638d 100644
--- a/plugins/woocommerce/client/legacy/css/order-review.scss
+++ b/plugins/woocommerce/client/legacy/css/order-review.scss
@@ -2,14 +2,69 @@
* Review Order page styles.
*
* Intentionally minimal. The active theme drives typography, color, and
- * button styling; only the interactive star-rating control needs bespoke
- * CSS to map radio inputs to SVG stars.
+ * button styling; only the per-item row layout and the interactive
+ * star-rating control need bespoke CSS.
*
* The star control renders with `flex-direction: row-reverse`: the DOM
* order is 5..1 so that sibling combinators (`~`) can fill all stars up
* to the selected/hovered one without relying on `:has()`.
*/
+.woocommerce-review-order {
+ &__items {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1.5em;
+ }
+
+ &__item {
+ padding: 1.5em 0;
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
+
+ &:first-child {
+ border-top: 0;
+ padding-top: 0;
+ }
+ }
+
+ &__item-row {
+ display: flex;
+ gap: 1.5em;
+ align-items: flex-start;
+ }
+
+ &__item-image {
+ flex: 0 0 120px;
+ max-width: 120px;
+ }
+
+ &__item-fields {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75em;
+ }
+
+ &__item-review-textarea {
+ width: 100%;
+ min-height: 6em;
+ resize: vertical;
+ }
+
+ @media (max-width: 600px) {
+ &__item-row {
+ flex-direction: column;
+ }
+
+ &__item-image {
+ flex: 0 0 auto;
+ max-width: 240px;
+ width: 100%;
+ }
+ }
+}
+
.woocommerce-star-rating {
display: inline-flex;
flex-direction: row-reverse;
diff --git a/plugins/woocommerce/client/legacy/js/frontend/order-review.js b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
index 9106800c736..9a6810368d0 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/order-review.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
@@ -73,9 +73,59 @@
syncCaption();
}
+ /**
+ * Enable / disable the review-order submit button based on whether at
+ * least one row has a rating selected.
+ *
+ * @param {HTMLFormElement} form `.woocommerce-review-order__form`
+ */
+ function initSubmitGate( form ) {
+ var submit = form.querySelector( '.woocommerce-review-order__submit' );
+ if ( ! submit ) {
+ if ( window.console && window.console.warn ) {
+ window.console.warn(
+ 'Review Order form is missing its submit button ' +
+ '(.woocommerce-review-order__submit); ' +
+ 'the rating-based gate will not run.'
+ );
+ }
+ return;
+ }
+
+ function syncSubmit() {
+ var anyChecked = !! form.querySelector(
+ '.woocommerce-star-rating__input:checked'
+ );
+ submit.disabled = ! anyChecked;
+ }
+
+ // Expose so external code (e.g. the AJAX submission handler in #64527)
+ // can re-evaluate the gate after async state changes.
+ form.syncReviewOrderSubmitGate = syncSubmit;
+
+ form.addEventListener( 'change', function ( event ) {
+ if (
+ event.target &&
+ event.target.classList &&
+ event.target.classList.contains(
+ 'woocommerce-star-rating__input'
+ )
+ ) {
+ syncSubmit();
+ }
+ } );
+
+ syncSubmit();
+ }
+
function init() {
var groups = document.querySelectorAll( '.woocommerce-star-rating' );
Array.prototype.forEach.call( groups, initGroup );
+
+ var forms = document.querySelectorAll(
+ '.woocommerce-review-order__form'
+ );
+ Array.prototype.forEach.call( forms, initSubmitGate );
}
if ( document.readyState === 'loading' ) {
diff --git a/plugins/woocommerce/templates/order/customer-review-order-row.php b/plugins/woocommerce/templates/order/customer-review-order-row.php
new file mode 100644
index 00000000000..31128db5ccf
--- /dev/null
+++ b/plugins/woocommerce/templates/order/customer-review-order-row.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Customer Review Order — per-item form row.
+ *
+ * Theme-overridable. Copy to `yourtheme/woocommerce/order/customer-review-order-row.php`.
+ *
+ * Renders one product per row: linked title, thumbnail, hidden inputs that
+ * tie the submission back to the order item, the accessible star-rating
+ * control, and the review textarea.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates
+ * @version 10.8.0
+ *
+ * @var WC_Order_Item_Product $item Order line item being rendered.
+ * @var WC_Product $product Product attached to the line item.
+ * @var WC_Order $order Order being reviewed.
+ * @var int $row_index Zero-based row index, used in input names.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+if ( ! $item instanceof WC_Order_Item_Product || ! $product instanceof WC_Product || ! $order instanceof WC_Order ) {
+ return;
+}
+
+$item_id = $item->get_id();
+$product_id = $product->get_id();
+$product_link = $product->is_visible() ? get_permalink( $product_id ) : '';
+$product_name = $item->get_name();
+$image_html = $product->get_image( 'woocommerce_thumbnail' );
+$rating_label_id = 'woocommerce-review-rating-label-' . $item_id;
+$review_label_id = 'woocommerce-review-text-label-' . $item_id;
+$review_input_id = 'woocommerce-review-text-' . $item_id;
+
+$rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::render(
+ array(
+ 'name' => 'reviews[' . $row_index . '][rating]',
+ 'id_prefix' => 'woocommerce-review-rating-' . $item_id,
+ 'label_id' => $rating_label_id,
+ )
+);
+?>
+<li class="woocommerce-review-order__item" data-row-index="<?php echo esc_attr( (string) $row_index ); ?>">
+ <p class="woocommerce-review-order__item-title">
+ <?php if ( $product_link ) : ?>
+ <a href="<?php echo esc_url( $product_link ); ?>" target="_blank" rel="noopener noreferrer">
+ <?php echo esc_html( $product_name ); ?>
+ <span class="screen-reader-text"><?php esc_html_e( '(opens in a new tab)', 'woocommerce' ); ?></span>
+ </a>
+ <?php else : ?>
+ <?php echo esc_html( $product_name ); ?>
+ <?php endif; ?>
+ </p>
+
+ <div class="woocommerce-review-order__item-row">
+ <div class="woocommerce-review-order__item-image">
+ <?php echo $image_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_image() returns escaped HTML. ?>
+ </div>
+
+ <div class="woocommerce-review-order__item-fields">
+ <input type="hidden" name="reviews[<?php echo esc_attr( (string) $row_index ); ?>][product_id]" value="<?php echo esc_attr( (string) $product_id ); ?>" />
+ <input type="hidden" name="reviews[<?php echo esc_attr( (string) $row_index ); ?>][order_item_id]" value="<?php echo esc_attr( (string) $item_id ); ?>" />
+
+ <div class="woocommerce-review-order__item-rating">
+ <p id="<?php echo esc_attr( $rating_label_id ); ?>" class="woocommerce-review-order__item-rating-label">
+ <?php
+ printf(
+ '%1$s <span class="required" aria-hidden="true">*</span><span class="screen-reader-text"> %2$s</span>',
+ esc_html__( 'Your rating', 'woocommerce' ),
+ esc_html__( 'Required', 'woocommerce' )
+ );
+ ?>
+ </p>
+ <?php echo $rating_control; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- StarRating::render() returns escaped HTML. ?>
+ </div>
+
+ <div class="woocommerce-review-order__item-review">
+ <label id="<?php echo esc_attr( $review_label_id ); ?>" for="<?php echo esc_attr( $review_input_id ); ?>" class="woocommerce-review-order__item-review-label">
+ <?php esc_html_e( 'Your review', 'woocommerce' ); ?>
+ </label>
+ <textarea
+ id="<?php echo esc_attr( $review_input_id ); ?>"
+ class="woocommerce-review-order__item-review-textarea"
+ name="reviews[<?php echo esc_attr( (string) $row_index ); ?>][text]"
+ rows="3"
+ placeholder="<?php esc_attr_e( 'Share your experience with this product...', 'woocommerce' ); ?>"
+ ></textarea>
+ </div>
+
+ <?php
+ /**
+ * Fires after the rating + textarea inside a Review Order form row.
+ *
+ * Lets extensions inject extra fields (e.g. an "I recommend this"
+ * checkbox) without overriding the whole template. Echo HTML directly
+ * — the surrounding container expects no return value.
+ *
+ * @since 10.8.0
+ *
+ * @param WC_Order_Item_Product $item The line item being reviewed.
+ * @param WC_Product $product The associated product.
+ * @param WC_Order $order The order.
+ * @param int $row_index Zero-based row index for input names.
+ */
+ do_action( 'woocommerce_review_order_form_fields', $item, $product, $order, $row_index );
+ ?>
+ </div>
+ </div>
+</li>
diff --git a/plugins/woocommerce/templates/order/customer-review-order.php b/plugins/woocommerce/templates/order/customer-review-order.php
index aecd676209b..a634755b026 100644
--- a/plugins/woocommerce/templates/order/customer-review-order.php
+++ b/plugins/woocommerce/templates/order/customer-review-order.php
@@ -68,6 +68,27 @@ $meta_parts = array_filter(
* @param WC_Order $order The order being reviewed.
*/
$items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
+
+// Pre-filter to the rows we can actually render so the <form> doesn't open
+// when every item is non-product or has a deleted product.
+$renderable_rows = array();
+foreach ( $items as $item ) {
+ if ( ! $item instanceof WC_Order_Item_Product ) {
+ continue;
+ }
+ $product = $item->get_product();
+ if ( ! $product instanceof WC_Product ) {
+ continue;
+ }
+ $renderable_rows[] = array(
+ 'item' => $item,
+ 'product' => $product,
+ );
+}
+
+// The Endpoint has already validated the URL key against the order key, so the
+// canonical value on the order is the right thing to echo into the form post.
+$order_key = (string) $order->get_order_key();
?>
<div class="woocommerce-review-order">
<p class="woocommerce-review-order__meta">
@@ -86,37 +107,40 @@ $items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $orde
<?php esc_html_e( '* Mandatory fields', 'woocommerce' ); ?>
</p>
- <?php if ( ! empty( $items ) ) : ?>
- <ul class="woocommerce-review-order__items">
- <?php foreach ( $items as $item ) : ?>
+ <?php if ( ! empty( $renderable_rows ) ) : ?>
+ <form
+ class="woocommerce-review-order__form"
+ method="post"
+ novalidate
+ >
+ <input type="hidden" name="order_id" value="<?php echo esc_attr( (string) $order->get_id() ); ?>" />
+ <input type="hidden" name="key" value="<?php echo esc_attr( $order_key ); ?>" />
+ <?php wp_nonce_field( 'woocommerce_submit_order_reviews', '_wcnonce' ); ?>
+
+ <ul class="woocommerce-review-order__items">
<?php
- if ( ! $item instanceof WC_Order_Item_Product ) {
- continue;
- }
- $product = $item->get_product();
- if ( ! $product instanceof WC_Product ) {
- continue;
+ foreach ( $renderable_rows as $row_index => $row ) {
+ wc_get_template(
+ 'order/customer-review-order-row.php',
+ array(
+ 'item' => $row['item'],
+ 'product' => $row['product'],
+ 'order' => $order,
+ 'row_index' => $row_index,
+ )
+ );
}
- $product_link = $product->is_visible() ? get_permalink( $product->get_id() ) : '';
- $product_name = $item->get_name();
- $image_html = $product->get_image( 'woocommerce_thumbnail' );
?>
- <li class="woocommerce-review-order__item">
- <p class="woocommerce-review-order__item-title">
- <?php if ( $product_link ) : ?>
- <a href="<?php echo esc_url( $product_link ); ?>"><?php echo esc_html( $product_name ); ?></a>
- <?php else : ?>
- <?php echo esc_html( $product_name ); ?>
- <?php endif; ?>
- </p>
- <div class="woocommerce-review-order__item-row">
- <div class="woocommerce-review-order__item-image">
- <?php echo $image_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_image() returns escaped HTML. ?>
- </div>
- <div class="woocommerce-review-order__item-form-placeholder"></div>
- </div>
- </li>
- <?php endforeach; ?>
- </ul>
+ </ul>
+
+ <div class="woocommerce-review-order__actions">
+ <button
+ type="submit"
+ class="woocommerce-review-order__submit button"
+ >
+ <?php esc_html_e( 'Submit reviews', 'woocommerce' ); ?>
+ </button>
+ </div>
+ </form>
<?php endif; ?>
</div>