Commit 48a2d7db55a for woocommerce
commit 48a2d7db55a826287a08ef21784521147c654317
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Tue Oct 20 12:15:13 2026 +0300
Fix accessibility and internationalization issues on the Review Order page (#69889)
* Fix accessibility and internationalization issues on the Review Order page
* Drop the hand-written changelog entry so the PR workflow generates it
* Add changefile(s) from automation for the following project(s): woocommerce
* Explain the gated submit button and register row live regions up front
* Assert the star fill against the hover regression the old test missed
* Apply the status role to a row region an override already rendered
* State the live constraint in the review-order comments
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/docs/features/email/customer-review-request.md b/docs/features/email/customer-review-request.md
index 22f1e029f04..9ec695334e1 100644
--- a/docs/features/email/customer-review-request.md
+++ b/docs/features/email/customer-review-request.md
@@ -149,8 +149,11 @@ pnpm --filter=@woocommerce/plugin-woocommerce test:e2e:default --grep "Customer
## Accessibility notes
- The star-rating control is a group of five native `<input type="radio">` elements inside a wrapper that carries the ARIA `role="radiogroup"` and `aria-labelledby` pointing at the rating label. The visual stars are SVG; the inputs themselves are visually hidden but remain in the accessibility tree.
-- Keyboard navigation is supported via Arrow keys and Home/End.
-- A live caption (`aria-live="polite"`) announces the selected rating's label.
-- The "Required" indicator on the rating label is exposed to screen readers via a `.screen-reader-text` span alongside the visual asterisk.
-- Inline mandatory-rating errors render with `role="alert"` so assistive tech announces them on submission.
+- Keyboard navigation is supported via Arrow keys and Home/End. The horizontal keys follow the visual order in both LTR and RTL; the vertical keys are direction-independent.
+- The caption below the stars is visual only: it is neither a live region nor the group's `aria-describedby` target, because each radio's own label already carries the rating text.
+- A rating is only required once the row has review text, so the group carries no `aria-required` and the label carries no required marker. Nothing on the page is unconditionally mandatory; `SubmissionHandler` skips any row submitted without a rating.
+- The mandatory-rating error paragraph is rendered empty by the row template so it exists before the script writes to it. It is not a live region: on submit the script focuses the first star of the first invalid group and scrolls the message to the top of the viewport, then the focused star into view, since at 600px and below a short viewport cannot hold both. Each radio points at the message through `aria-invalid` and `aria-describedby`.
+- Per-row outcomes are written into the row's pre-rendered `role="status"` paragraph. The script re-creates either paragraph if an override drops it, but a live region added at the moment its text changes is not announced, so overrides should keep them.
+- The Submit button is gated with `aria-disabled` rather than `disabled`, so it stays focusable while the form is incomplete. Because a focusable button that does nothing needs an explanation, the script appends a hint next to it and points the button at it with `aria-describedby`, then hides the hint and drops the link once a row has been edited.
+- After a successful submission focus moves to the thank-you heading, which is why the success block's `.woocommerce-review-order__empty-title` in `customer-review-order.php` carries `tabindex="-1"`. The standalone empty-state template needs none, since that is a full page load. The page heading carries one so dismissing the disabled-products notice can hand focus back.
- The host-page heading suppression keeps the chrome `<h1>` from duplicating the body `<h1>`, avoiding the "page announced twice" issue with screen readers.
diff --git a/plugins/woocommerce/changelog/69889-wooplug-6602-review-order-a11y-i18n-sweep b/plugins/woocommerce/changelog/69889-wooplug-6602-review-order-a11y-i18n-sweep
new file mode 100644
index 00000000000..3302ae28f49
--- /dev/null
+++ b/plugins/woocommerce/changelog/69889-wooplug-6602-review-order-a11y-i18n-sweep
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix accessibility and internationalization issues on the Review Order page.
diff --git a/plugins/woocommerce/client/legacy/css/order-review.scss b/plugins/woocommerce/client/legacy/css/order-review.scss
index f6d2cd88f51..f1c68f8b255 100644
--- a/plugins/woocommerce/client/legacy/css/order-review.scss
+++ b/plugins/woocommerce/client/legacy/css/order-review.scss
@@ -7,6 +7,7 @@
.woocommerce-review-order__title,
.woocommerce-review-order__intro,
+ // A template override may render the legend; hide it too.
.woocommerce-review-order__legend,
.woocommerce-review-order__notice,
.woocommerce-review-order__form {
@@ -14,6 +15,17 @@
}
}
+ // Nested for the weight to beat a theme's own button rules.
+ & &__submit[aria-disabled="true"] {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &__submit-hint {
+ margin: 0.5em 0 0;
+ font-size: 0.875em;
+ }
+
&__items {
list-style: none;
padding: 0;
@@ -35,6 +47,7 @@
font-size: 1.25em;
line-height: 1.2;
font-weight: 500;
+ overflow-wrap: anywhere;
a {
text-decoration: underline;
@@ -84,19 +97,31 @@
}
&__item-review-textarea {
+ // Border-box or the theme's border and padding push `width: 100%`
+ // past the column, which breaks reflow at narrow widths.
+ box-sizing: border-box;
width: 100%;
min-height: 6em;
resize: vertical;
}
+ // Rendered empty, so the margin must not leave a gap when it is.
&__item-rating-error {
margin: 0.5em 0 1em;
color: var(--wc-red, #a00);
+
+ &:empty {
+ margin: 0;
+ }
}
&__item-status {
margin: 0.5em 0 0;
+ &:empty {
+ margin: 0;
+ }
+
&--ok {
color: var(--wc-green, #007518);
}
@@ -170,12 +195,13 @@
gap: 0.25em;
line-height: 1;
+ // Spacing lives in the labels' padding rather than a `gap`, so no pixel
+ // between two stars is outside a tap target.
&__stars {
display: inline-flex;
flex-direction: row-reverse;
justify-content: flex-end;
align-items: center;
- gap: 4px;
}
// Visually hide radios, keep them in the a11y tree.
@@ -195,7 +221,7 @@
&__star {
cursor: pointer;
display: inline-flex;
- padding: 0;
+ padding: 0 2px;
margin: 0;
color: currentcolor;
}
@@ -211,25 +237,19 @@
width: 24px;
height: 24px;
fill: currentcolor;
- opacity: 0.3;
+ // Keeps the 3:1 of WCAG 1.4.11 for any text colour down to #3e3e3e.
+ opacity: 0.55;
transition: opacity 100ms ease-in-out;
}
// Checked input fills its own star + every following sibling (visual left).
- &__input:checked ~ .woocommerce-star-rating__star .woocommerce-star-rating__icon {
- opacity: 1;
- }
-
- &__stars:hover .woocommerce-star-rating__icon {
- opacity: 0.3;
- }
-
- &__star:hover .woocommerce-star-rating__icon,
- &__star:hover ~ .woocommerce-star-rating__star .woocommerce-star-rating__icon {
+ &__input:checked ~ &__star &__icon {
opacity: 1;
}
- &__input:focus-visible + .woocommerce-star-rating__star {
+ // Not `:focus-visible`: the clipped radios have no other indicator, and
+ // script-moved focus after a click does not match it.
+ &__input:focus + .woocommerce-star-rating__star {
outline: 2px solid currentcolor;
outline-offset: 2px;
}
diff --git a/plugins/woocommerce/client/legacy/js/frontend/order-review.js b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
index 852dd89affa..476d1e2907f 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/order-review.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
@@ -7,6 +7,94 @@
'use strict';
var ERROR_CLASS = 'woocommerce-review-order__item-rating-error';
+ var STATUS_CLASS = 'woocommerce-review-order__item-status';
+ var HINT_CLASS = 'woocommerce-review-order__submit-hint';
+ var uid = 0;
+
+ /**
+ * Add or remove one token from a space-separated attribute.
+ *
+ * @param {HTMLElement} el
+ * @param {string} attr
+ * @param {string} token
+ * @param {boolean} on
+ */
+ function toggleToken( el, attr, token, on ) {
+ var tokens = ( el.getAttribute( attr ) || '' )
+ .split( /\s+/ )
+ .filter( function ( entry ) {
+ return entry && entry !== token;
+ } );
+ if ( on ) {
+ tokens.push( token );
+ }
+ if ( tokens.length ) {
+ el.setAttribute( attr, tokens.join( ' ' ) );
+ } else {
+ el.removeAttribute( attr );
+ }
+ }
+
+ /**
+ * The row's message element, created only for a theme override that
+ * dropped the paragraph the templates render up front.
+ *
+ * @param {HTMLElement} row `.woocommerce-review-order__item`
+ * @param {string} className Element class.
+ * @param {?string} role ARIA role, or null for a plain paragraph.
+ * @return {HTMLElement}
+ */
+ function rowRegion( row, className, role ) {
+ // Any depth: a theme override may wrap it, and a child-only lookup
+ // would miss it and add another on every call.
+ var region = row.querySelector( '.' + className );
+ if ( region ) {
+ // An override may render the paragraph without the role. Only
+ // when it has none, so a deliberate `role="alert"` survives;
+ // `role=""` counts as none, since an empty token list leaves the
+ // element without a role.
+ var existingRole = ( region.getAttribute( 'role' ) || '' ).trim();
+ if ( role && ! existingRole ) {
+ region.setAttribute( 'role', role );
+ }
+ return region;
+ }
+
+ region = document.createElement( 'p' );
+ region.className = className;
+ if ( role ) {
+ region.setAttribute( 'role', role );
+ }
+
+ if ( STATUS_CLASS === className ) {
+ row.appendChild( region );
+ return region;
+ }
+
+ // Under the title, not below the stars, so the message is at the top
+ // of the row on tall layouts.
+ var title = row.querySelector(
+ '.woocommerce-review-order__item-title'
+ );
+ if ( title ) {
+ title.parentNode.insertBefore( region, title.nextSibling );
+ } else {
+ row.insertBefore( region, row.firstChild );
+ }
+ return region;
+ }
+
+ /**
+ * @param {HTMLElement} el
+ * @return {string} The element's id, assigning a generated one if needed.
+ */
+ function idOf( el ) {
+ if ( ! el.id ) {
+ uid += 1;
+ el.id = 'woocommerce-review-order-region-' + uid;
+ }
+ return el.id;
+ }
/**
* @param {HTMLElement} container `.woocommerce-star-rating` element.
@@ -15,8 +103,9 @@
var inputs = Array.prototype.slice.call(
container.querySelectorAll( '.woocommerce-star-rating__input' )
);
- var captionId = container.getAttribute( 'aria-describedby' );
- var caption = captionId ? document.getElementById( captionId ) : null;
+ var caption = container.querySelector(
+ '.woocommerce-star-rating__caption'
+ );
function syncCaption() {
if ( ! caption ) {
@@ -36,21 +125,30 @@
input.dispatchEvent( new window.Event( 'change', { bubbles: true } ) );
}
- // DOM order is 5..1; under row-reverse the next visual star is the previous DOM input.
+ // DOM order is 5..1, so the next visual star is the previous input;
+ // RTL flips the row again, taking the horizontal keys with it.
inputs.forEach( function ( input, index ) {
input.addEventListener( 'change', syncCaption );
input.addEventListener( 'keydown', function ( event ) {
+ var rtl =
+ 'rtl' ===
+ window.getComputedStyle( container ).direction;
+ var forward = ( index - 1 + inputs.length ) % inputs.length;
+ var backward = ( index + 1 ) % inputs.length;
var nextIndex = null;
switch ( event.key ) {
case 'ArrowRight':
+ nextIndex = rtl ? backward : forward;
+ break;
case 'ArrowDown':
- nextIndex =
- ( index - 1 + inputs.length ) % inputs.length;
+ nextIndex = forward;
break;
case 'ArrowLeft':
+ nextIndex = rtl ? forward : backward;
+ break;
case 'ArrowUp':
- nextIndex = ( index + 1 ) % inputs.length;
+ nextIndex = backward;
break;
case 'Home':
nextIndex = inputs.length - 1;
@@ -114,10 +212,45 @@
);
}
+ /**
+ * @param {HTMLElement} submit Submit button.
+ * @return {boolean} Whether the dirty gate is currently closed.
+ */
+ function isGated( submit ) {
+ return 'true' === submit.getAttribute( 'aria-disabled' );
+ }
+
+ /**
+ * The hint that says why a gated submit does nothing. Created here rather
+ * than rendered by the template, because the gate only exists when this
+ * script runs.
+ *
+ * @param {HTMLElement} submit Submit button.
+ * @return {HTMLElement}
+ */
+ function submitHint( submit ) {
+ var parent = submit.parentNode || submit;
+ var hint = parent.querySelector( '.' + HINT_CLASS );
+ if ( hint ) {
+ return hint;
+ }
+
+ var i18n = ( window.wcOrderReview && window.wcOrderReview.i18n ) || {};
+ hint = document.createElement( 'p' );
+ hint.className = HINT_CLASS;
+ hint.textContent =
+ i18n.submit_hint || 'Add or change a rating or review to submit.';
+ parent.appendChild( hint );
+ return hint;
+ }
+
/**
* Enable / disable the review-order submit button based on whether at
* least one row has been edited since page load.
*
+ * `aria-disabled` rather than `disabled`, so the button stays in the tab
+ * order and a screen reader can reach it.
+ *
* @param {HTMLFormElement} form `.woocommerce-review-order__form`
*/
function initSubmitGate( form ) {
@@ -136,11 +269,36 @@
var rows = Array.prototype.slice.call(
form.querySelectorAll( '.woocommerce-review-order__item' )
);
+ var hint = submitHint( submit );
function syncSubmit() {
- submit.disabled = ! rows.some( isRowDirty );
+ var dirty = rows.some( isRowDirty );
+ var ready = ! form.wcReviewOrderSubmitting && dirty;
+ submit.setAttribute( 'aria-disabled', ready ? 'false' : 'true' );
+ // Keyed on `dirty`, not `ready`: an in-flight submit closes the
+ // gate too, and the hint would then name the wrong reason. An
+ // override that renders the form with no rows offers nothing to
+ // add or change either.
+ var explain = ! dirty && rows.length > 0;
+ hint.hidden = ! explain;
+ toggleToken( submit, 'aria-describedby', idOf( hint ), explain );
}
+ // `aria-disabled` does not stop the browser submitting, and the AJAX
+ // handler bails out without `data-ajax-url`. `requestSubmit()` raises
+ // no click, so both events need the guard.
+ submit.addEventListener( 'click', function ( event ) {
+ if ( isGated( submit ) ) {
+ event.preventDefault();
+ }
+ } );
+
+ form.addEventListener( 'submit', function ( event ) {
+ if ( isGated( submit ) ) {
+ event.preventDefault();
+ }
+ } );
+
// Expose so initAjaxSubmit can re-run the gate after the request completes.
form.syncReviewOrderSubmitGate = syncSubmit;
@@ -151,41 +309,86 @@
}
/**
+ * Show or clear a row's rating error. Not announced on its own: the
+ * caller focuses the first invalid rating, and `aria-describedby` carries
+ * the rest when the customer reaches them.
+ *
* @param {HTMLElement} row `.woocommerce-review-order__item`
* @param {boolean} visible Whether the error should be shown.
*/
function setRowRatingError( row, visible ) {
- var existing = row.querySelector( ':scope > .' + ERROR_CLASS );
+ var region = rowRegion( row, ERROR_CLASS, null );
+ // On the radios, not the group: a screen reader reports these for the
+ // focused element, and focus lands on a radio.
+ var inputs = Array.prototype.slice.call(
+ row.querySelectorAll( '.woocommerce-star-rating__input' )
+ );
+
if ( ! visible ) {
- if ( existing ) {
- existing.parentNode.removeChild( existing );
- }
- return;
- }
- if ( existing ) {
+ region.textContent = '';
+ inputs.forEach( function ( input ) {
+ if ( ! input.hasAttribute( 'aria-invalid' ) ) {
+ return;
+ }
+ input.removeAttribute( 'aria-invalid' );
+ toggleToken( input, 'aria-describedby', region.id, false );
+ } );
return;
}
+
var i18n = ( window.wcOrderReview && window.wcOrderReview.i18n ) || {};
- var msg =
+ region.textContent =
i18n.rating_required ||
'Please rate this product before submitting your review.';
- var note = document.createElement( 'p' );
- note.className = ERROR_CLASS;
- note.setAttribute( 'role', 'alert' );
- note.textContent = msg;
-
- // Anchor the error directly under the product title when the row has
- // one so the customer sees the message at the top of the row, not
- // buried below the stars on tall layouts. Fall back to prepending
- // into the row itself so the error is never silently dropped if a
- // theme override removes the title element.
- var title = row.querySelector(
- '.woocommerce-review-order__item-title'
- );
- if ( title ) {
- title.parentNode.insertBefore( note, title.nextSibling );
- } else {
- row.insertBefore( note, row.firstChild );
+
+ inputs.forEach( function ( input ) {
+ input.setAttribute( 'aria-invalid', 'true' );
+ toggleToken( input, 'aria-describedby', idOf( region ), true );
+ } );
+ }
+
+ /**
+ * Focus a group's first star in reading order, which the reversed markup
+ * puts last in DOM order.
+ *
+ * @param {HTMLElement} scope Rating group, or any ancestor of one.
+ * @return {?HTMLElement} The focused radio, or null.
+ */
+ function focusFirstStar( scope ) {
+ if ( ! scope ) {
+ return null;
+ }
+ var inputs = scope.querySelectorAll( '.woocommerce-star-rating__input' );
+ var first = inputs[ inputs.length - 1 ];
+ if ( ! first || typeof first.focus !== 'function' ) {
+ return null;
+ }
+ // The radios are 1px, so letting the browser scroll to one can leave
+ // the message it points at off-screen.
+ first.focus( { preventScroll: true } );
+ return first;
+ }
+
+ /**
+ * Bring an invalid row's rating error into view, then its focused star.
+ *
+ * @param {HTMLElement} input Radio carrying `aria-invalid`.
+ * @param {?HTMLElement} focused Radio `focusFirstStar` focused, if any.
+ */
+ function revealRatingError( input, focused ) {
+ var row = input.closest( '.woocommerce-review-order__item' );
+ var message = row && row.querySelector( '.' + ERROR_CLASS );
+ if ( message && typeof message.scrollIntoView === 'function' ) {
+ // `start`, not `center`: in the stacked layout the stars sit below
+ // the image, and centring the message pushes them off-screen.
+ message.scrollIntoView( { block: 'start' } );
+ }
+
+ // Stacked, the two are ~360px apart and a short viewport cannot hold
+ // both, so the ring wins: it is the clipped radios' only indicator.
+ var label = focused && focused.labels && focused.labels[ 0 ];
+ if ( label && typeof label.scrollIntoView === 'function' ) {
+ label.scrollIntoView( { block: 'nearest' } );
}
}
@@ -198,6 +401,13 @@
form.querySelectorAll( '.woocommerce-review-order__item' )
);
+ // Up front, because a live region created in the same step that fills
+ // it is not announced, and an override may render neither paragraph.
+ rows.forEach( function ( row ) {
+ rowRegion( row, ERROR_CLASS, null );
+ rowRegion( row, STATUS_CLASS, 'status' );
+ } );
+
function validate() {
var ok = true;
rows.forEach( function ( row ) {
@@ -241,12 +451,7 @@
* @param {string} [text] Optional message override.
*/
function renderRowStatus( row, status, text ) {
- var existing = row.querySelector(
- '.woocommerce-review-order__item-status'
- );
- if ( existing ) {
- existing.parentNode.removeChild( existing );
- }
+ var region = rowRegion( row, STATUS_CLASS, 'status' );
var i18n =
( window.wcOrderReview && window.wcOrderReview.i18n ) || {};
var defaults = {
@@ -257,13 +462,12 @@
error:
i18n.error || 'Something went wrong, please try again.',
};
- var note = document.createElement( 'p' );
- note.className =
- 'woocommerce-review-order__item-status woocommerce-review-order__item-status--' +
- status;
- note.setAttribute( 'role', 'status' );
- note.textContent = text || defaults[ status ] || defaults.error;
- row.appendChild( note );
+
+ [ 'ok', 'pending_moderation', 'error' ].forEach( function ( name ) {
+ region.classList.remove( STATUS_CLASS + '--' + name );
+ } );
+ region.classList.add( STATUS_CLASS, STATUS_CLASS + '--' + status );
+ region.textContent = text || defaults[ status ] || defaults.error;
}
/**
@@ -282,22 +486,29 @@
form.addEventListener( 'submit', function ( event ) {
event.preventDefault();
+ var submit = form.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+ if ( form.wcReviewOrderSubmitting || ( submit && isGated( submit ) ) ) {
+ return;
+ }
+
if ( ! validate() ) {
- var firstError = form.querySelector( '.' + ERROR_CLASS );
- if ( firstError && typeof firstError.scrollIntoView === 'function' ) {
- firstError.scrollIntoView( {
- behavior: 'smooth',
- block: 'center',
- } );
+ var firstInvalid = form.querySelector(
+ '.woocommerce-star-rating__input[aria-invalid="true"]'
+ );
+ if ( firstInvalid ) {
+ var focused = focusFirstStar(
+ firstInvalid.closest( '.woocommerce-star-rating' )
+ );
+ revealRatingError( firstInvalid, focused );
}
return;
}
- var submit = form.querySelector(
- '.woocommerce-review-order__submit'
- );
+ form.wcReviewOrderSubmitting = true;
if ( submit ) {
- submit.disabled = true;
+ submit.setAttribute( 'aria-disabled', 'true' );
}
window
@@ -368,6 +579,19 @@
);
if ( success ) {
success.hidden = false;
+ // `is-success` hides the form and the
+ // focused button in it. The heading, not
+ // the container: an unnamed div announces
+ // nothing in some screen readers.
+ var target =
+ success.querySelector( 'h1, h2' ) ||
+ success;
+ if ( ! target.hasAttribute( 'tabindex' ) ) {
+ target.setAttribute( 'tabindex', '-1' );
+ }
+ if ( typeof target.focus === 'function' ) {
+ target.focus( { preventScroll: true } );
+ }
}
if (
typeof wrapper.scrollIntoView === 'function'
@@ -397,10 +621,11 @@
);
} )
.then( function () {
+ form.wcReviewOrderSubmitting = false;
if ( typeof form.syncReviewOrderSubmitGate === 'function' ) {
form.syncReviewOrderSubmitGate();
} else if ( submit ) {
- submit.disabled = false;
+ submit.setAttribute( 'aria-disabled', 'false' );
}
} );
} );
@@ -418,6 +643,19 @@
}
dismiss.addEventListener( 'click', function () {
notice.classList.add( 'woocommerce-review-order__notice--hidden' );
+
+ // The dismiss button goes with the notice, so focus would fall to
+ // the document. Tabindex set here for theme overrides without it.
+ var wrapper = notice.closest( '.woocommerce-review-order' );
+ var heading = ( wrapper || document ).querySelector(
+ '.woocommerce-review-order__title'
+ );
+ if ( heading && typeof heading.focus === 'function' ) {
+ if ( ! heading.hasAttribute( 'tabindex' ) ) {
+ heading.setAttribute( 'tabindex', '-1' );
+ }
+ heading.focus();
+ }
} );
}
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/order-review.js b/plugins/woocommerce/client/legacy/js/frontend/test/order-review.js
index 45954498c01..9c710b08714 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/test/order-review.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/order-review.js
@@ -56,9 +56,13 @@ describe( 'Review Order frontend behavior', () => {
} );
test( 'dismisses disabled-products notice with the production hidden class', () => {
+ // No tabindex on the heading, as an older theme override would have.
document.body.innerHTML = `
- <div class="woocommerce-review-order__notice">
- <button class="woocommerce-review-order__notice-dismiss" type="button">Dismiss</button>
+ <div class="woocommerce-review-order">
+ <h1 class="woocommerce-review-order__title">Review your order</h1>
+ <div class="woocommerce-review-order__notice">
+ <button class="woocommerce-review-order__notice-dismiss" type="button">Dismiss</button>
+ </div>
</div>
`;
@@ -76,6 +80,9 @@ describe( 'Review Order frontend behavior', () => {
'woocommerce-review-order__notice--hidden'
)
).toBe( true );
+ expect( document.activeElement ).toBe(
+ document.querySelector( '.woocommerce-review-order__title' )
+ );
} );
test( 'blocks a text-only review until a rating is selected', async () => {
@@ -96,8 +103,14 @@ describe( 'Review Order frontend behavior', () => {
<form class="woocommerce-review-order__form" data-ajax-url="${ ajaxUrl }">
<div class="woocommerce-review-order__item" data-initial-rating="0" data-initial-text="">
<h3 class="woocommerce-review-order__item-title">Product</h3>
- <div class="woocommerce-star-rating">
- <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="5 stars" />
+ <p id="rating-error-1" class="woocommerce-review-order__item-rating-error"></p>
+ <div class="woocommerce-star-rating" aria-describedby="rating-caption-1">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="Perfect" />
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="4" data-label="Good" />
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="3" data-label="Average" />
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="2" data-label="Not that bad" />
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="1" data-label="Very poor" />
+ <span id="rating-caption-1" class="woocommerce-star-rating__caption"></span>
</div>
<textarea class="woocommerce-review-order__item-review-textarea" name="review"></textarea>
</div>
@@ -111,7 +124,7 @@ describe( 'Review Order frontend behavior', () => {
'.woocommerce-review-order__form'
);
const rating = document.querySelector(
- '.woocommerce-star-rating__input'
+ '.woocommerce-star-rating__input[value="5"]'
);
const textarea = form.querySelector(
'.woocommerce-review-order__item-review-textarea'
@@ -120,25 +133,48 @@ describe( 'Review Order frontend behavior', () => {
'.woocommerce-review-order__submit'
);
- expect( submit.disabled ).toBe( true );
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'true' );
textarea.value = 'Text-only review';
textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) );
- expect( submit.disabled ).toBe( false );
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'false' );
submit.click();
- expect( form.querySelectorAll( '[role="alert"]' ) ).toHaveLength( 1 );
- expect( form.querySelector( '[role="alert"]' ).textContent ).toBe(
- ratingRequired
+ const message = form.querySelector(
+ '.woocommerce-review-order__item-rating-error'
);
+ expect( message.textContent ).toBe( ratingRequired );
expect( window.fetch ).not.toHaveBeenCalled();
+ // The element the template rendered, not a new one.
+ expect( message.id ).toBe( 'rating-error-1' );
+ expect( message.hasAttribute( 'role' ) ).toBe( false );
+
+ // Reachable from the radio that takes focus, not from the wrapper.
+ const lowest = form.querySelector(
+ '.woocommerce-star-rating__input[value="1"]'
+ );
+ expect( lowest.getAttribute( 'aria-invalid' ) ).toBe( 'true' );
+ expect( lowest.getAttribute( 'aria-describedby' ) ).toContain(
+ message.id
+ );
+ expect( document.activeElement ).toBe( lowest );
rating.checked = true;
rating.dispatchEvent( new Event( 'change', { bubbles: true } ) );
- expect( form.querySelectorAll( '[role="alert"]' ) ).toHaveLength( 0 );
- expect( submit.disabled ).toBe( false );
+ // Kept in the DOM and reused for the next message.
+ expect(
+ form.querySelectorAll(
+ '.woocommerce-review-order__item-rating-error'
+ )
+ ).toHaveLength( 1 );
+ expect( message.textContent ).toBe( '' );
+ expect( lowest.hasAttribute( 'aria-invalid' ) ).toBe( false );
+ expect( lowest.getAttribute( 'aria-describedby' ) || '' ).not.toContain(
+ message.id
+ );
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'false' );
submit.click();
@@ -150,4 +186,453 @@ describe( 'Review Order frontend behavior', () => {
await Promise.resolve();
} );
+
+ test( 'keeps the submit button reachable while gating it', () => {
+ const ajaxUrl = 'https://example.test/wp-admin/admin-ajax.php';
+ window.fetch = jest.fn();
+ global.fetch = window.fetch;
+ window.FormData = TestFormData;
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" data-ajax-url="${ ajaxUrl }">
+ <div class="woocommerce-review-order__item" data-initial-rating="0" data-initial-text="">
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="5 stars" />
+ </div>
+ </div>
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const submit = document.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+
+ // `disabled` would drop it out of the tab order with no explanation.
+ expect( submit.disabled ).toBe( false );
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'true' );
+
+ submit.click();
+
+ expect( window.fetch ).not.toHaveBeenCalled();
+ } );
+
+ test( 'blocks a gated submit on a form the AJAX handler skipped', () => {
+ // No `data-ajax-url`, so `initAjaxSubmit` bails and the gate's own
+ // handler is all that stands between a dimmed button and a POST.
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" method="post" action="https://example.test/wp-admin/admin-ajax.php">
+ <div class="woocommerce-review-order__item" data-initial-rating="0" data-initial-text="">
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="Perfect" />
+ </div>
+ </div>
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const form = document.querySelector(
+ '.woocommerce-review-order__form'
+ );
+ const submit = form.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+ // Records whether the gate cancelled first, then cancels so jsdom
+ // does not try to navigate.
+ const preventedByGate = [];
+ const submitted = jest.fn( ( event ) => {
+ preventedByGate.push( event.defaultPrevented );
+ event.preventDefault();
+ } );
+ form.addEventListener( 'submit', submitted );
+
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'true' );
+ submit.click();
+ expect( submitted ).not.toHaveBeenCalled();
+
+ // Once the gate opens the form submits as it always did.
+ const rating = form.querySelector( '.woocommerce-star-rating__input' );
+ rating.checked = true;
+ rating.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'false' );
+ submit.click();
+ expect( submitted ).toHaveBeenCalledTimes( 1 );
+ expect( preventedByGate ).toEqual( [ false ] );
+ } );
+
+ test( 'blocks a gated requestSubmit, which raises no click', () => {
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" method="post" action="https://example.test/wp-admin/admin-ajax.php">
+ <div class="woocommerce-review-order__item" data-initial-rating="0" data-initial-text="">
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="Perfect" />
+ </div>
+ </div>
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const form = document.querySelector(
+ '.woocommerce-review-order__form'
+ );
+ const submit = form.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+ const prevented = [];
+ form.addEventListener( 'submit', ( event ) => {
+ prevented.push( event.defaultPrevented );
+ event.preventDefault();
+ } );
+
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'true' );
+ form.requestSubmit();
+
+ // The click guard cannot see this path, so the form's own guard is
+ // what has to cancel it.
+ expect( prevented ).toEqual( [ true ] );
+
+ const rating = form.querySelector( '.woocommerce-star-rating__input' );
+ rating.checked = true;
+ rating.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+
+ form.requestSubmit();
+ expect( prevented ).toEqual( [ true, false ] );
+ } );
+
+ test( 'registers both row regions up front for an override that drops them', () => {
+ // A row template override that renders neither paragraph.
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" method="post" action="https://example.test/wp-admin/admin-ajax.php">
+ <div class="woocommerce-review-order__item" data-row-index="0" data-initial-rating="0" data-initial-text="">
+ <p class="woocommerce-review-order__item-title">CRR Product</p>
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="Perfect" />
+ </div>
+ <textarea class="woocommerce-review-order__item-review-textarea"></textarea>
+ </div>
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const row = document.querySelector(
+ '.woocommerce-review-order__item'
+ );
+ const status = row.querySelector(
+ '.woocommerce-review-order__item-status'
+ );
+ const error = row.querySelector(
+ '.woocommerce-review-order__item-rating-error'
+ );
+
+ // A live region first created when its text is written is not
+ // announced, so both have to exist and be empty before any write.
+ expect( status.getAttribute( 'role' ) ).toBe( 'status' );
+ expect( status.textContent ).toBe( '' );
+ expect( error ).not.toBeNull();
+ expect( error.textContent ).toBe( '' );
+ // The error text is deliberately not announced on its own, so the
+ // paragraph must carry no role at all.
+ expect( error.hasAttribute( 'role' ) ).toBe( false );
+ } );
+
+ test.each( [
+ [ 'no role', '', 'status' ],
+ // An empty token list leaves the element without a role.
+ [ 'an empty role', ' role=""', 'status' ],
+ [ 'a blank role', ' role=" "', 'status' ],
+ [ 'the shipped role', ' role="status"', 'status' ],
+ [ 'its own role', ' role="alert"', 'alert' ],
+ ] )(
+ 'announces an override status paragraph that ships with %s',
+ ( label, roleAttr, expected ) => {
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" method="post" action="https://example.test/wp-admin/admin-ajax.php">
+ <div class="woocommerce-review-order__item" data-row-index="0" data-initial-rating="0" data-initial-text="">
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="Perfect" />
+ </div>
+ <p class="woocommerce-review-order__item-status"${ roleAttr }></p>
+ </div>
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const regions = document.querySelectorAll(
+ '.woocommerce-review-order__item-status'
+ );
+ // Reused, not duplicated beside the override's own.
+ expect( regions ).toHaveLength( 1 );
+ expect( regions[ 0 ].getAttribute( 'role' ) ).toBe( expected );
+ }
+ );
+
+ test( 'explains a gated submit with a hint the button points at', () => {
+ window.wcOrderReview = {
+ i18n: { submit_hint: 'Rate or review something first.' },
+ };
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" method="post" action="https://example.test/wp-admin/admin-ajax.php">
+ <div class="woocommerce-review-order__item" data-initial-rating="0" data-initial-text="">
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="Perfect" />
+ </div>
+ </div>
+ <div class="woocommerce-review-order__actions">
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </div>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const form = document.querySelector(
+ '.woocommerce-review-order__form'
+ );
+ const submit = form.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+ const hint = form.querySelector(
+ '.woocommerce-review-order__submit-hint'
+ );
+
+ expect( hint.parentElement ).toBe( submit.parentElement );
+ expect( hint.textContent ).toBe( 'Rate or review something first.' );
+ expect( hint.hidden ).toBe( false );
+ expect(
+ submit.getAttribute( 'aria-describedby' ).split( /\s+/ )
+ ).toContain( hint.id );
+
+ const rating = form.querySelector( '.woocommerce-star-rating__input' );
+ rating.checked = true;
+ rating.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+
+ // Nothing left to explain once the button works.
+ expect( hint.hidden ).toBe( true );
+ expect( submit.hasAttribute( 'aria-describedby' ) ).toBe( false );
+ } );
+
+ test( 'keeps the hint quiet on an override that renders no rows', () => {
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" method="post" action="https://example.test/wp-admin/admin-ajax.php">
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const form = document.querySelector(
+ '.woocommerce-review-order__form'
+ );
+ const submit = form.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+ const hint = form.querySelector(
+ '.woocommerce-review-order__submit-hint'
+ );
+
+ // Nothing to add or change, so the gate stays shut and the hint must
+ // not name an action the page does not offer.
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'true' );
+ expect( hint.hidden ).toBe( true );
+ expect( submit.hasAttribute( 'aria-describedby' ) ).toBe( false );
+ } );
+
+ test( 'moves focus into the thank-you view once the form is hidden', async () => {
+ const ajaxUrl = 'https://example.test/wp-admin/admin-ajax.php';
+ window.fetch = jest.fn( () =>
+ Promise.resolve( {
+ json: () =>
+ Promise.resolve( {
+ success: true,
+ data: { results: { 0: { status: 'ok' } } },
+ } ),
+ } )
+ );
+ global.fetch = window.fetch;
+ window.FormData = TestFormData;
+ document.body.innerHTML = `
+ <div class="woocommerce-review-order">
+ <form class="woocommerce-review-order__form" data-ajax-url="${ ajaxUrl }">
+ <div class="woocommerce-review-order__item" data-row-index="0" data-initial-rating="0" data-initial-text="">
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="5 stars" />
+ </div>
+ <p id="row-status-1" class="woocommerce-review-order__item-status" role="status"></p>
+ </div>
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ <div class="woocommerce-review-order__success" hidden>
+ <h1 class="woocommerce-review-order__empty-title">Thank you</h1>
+ </div>
+ </div>
+ `;
+
+ initializeReviewOrder();
+
+ const rating = document.querySelector(
+ '.woocommerce-star-rating__input'
+ );
+ rating.checked = true;
+ rating.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+
+ document.querySelector( '.woocommerce-review-order__submit' ).click();
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+
+ const success = document.querySelector(
+ '.woocommerce-review-order__success'
+ );
+ expect( success.hidden ).toBe( false );
+ // The heading, not the unnamed container. The fixture leaves the
+ // tabindex off, as an older theme override would.
+ expect( document.activeElement ).toBe( success.querySelector( 'h1' ) );
+
+ // The region the template rendered: a replaced node would be a fresh
+ // live region and go unannounced.
+ const status = document.querySelector(
+ '.woocommerce-review-order__item-status'
+ );
+ expect( status.id ).toBe( 'row-status-1' );
+ expect( status.textContent ).not.toBe( '' );
+ } );
+
+ test( 'sends one request for a double click and reopens the gate after a failure', async () => {
+ const ajaxUrl = 'https://example.test/wp-admin/admin-ajax.php';
+ let reject;
+ window.fetch = jest.fn(
+ () =>
+ new Promise( ( resolve, rejectFn ) => {
+ reject = rejectFn;
+ } )
+ );
+ global.fetch = window.fetch;
+ window.FormData = TestFormData;
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" data-ajax-url="${ ajaxUrl }">
+ <div class="woocommerce-review-order__item" data-row-index="0" data-initial-rating="0" data-initial-text="">
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="Perfect" />
+ </div>
+ </div>
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const form = document.querySelector(
+ '.woocommerce-review-order__form'
+ );
+ const submit = form.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+ const rating = form.querySelector( '.woocommerce-star-rating__input' );
+ rating.checked = true;
+ rating.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+
+ submit.click();
+ submit.click();
+ expect( window.fetch ).toHaveBeenCalledTimes( 1 );
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'true' );
+
+ reject( new Error( 'network' ) );
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+
+ // A failed request has to leave the customer able to try again.
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'false' );
+ } );
+
+ test( 'keeps the gate shut while a request is in flight', () => {
+ const ajaxUrl = 'https://example.test/wp-admin/admin-ajax.php';
+ window.fetch = jest.fn( () => new Promise( () => {} ) );
+ global.fetch = window.fetch;
+ window.FormData = TestFormData;
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" data-ajax-url="${ ajaxUrl }">
+ <div class="woocommerce-review-order__item" data-row-index="0" data-initial-rating="0" data-initial-text="">
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="Perfect" />
+ </div>
+ <textarea class="woocommerce-review-order__item-review-textarea"></textarea>
+ </div>
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const form = document.querySelector(
+ '.woocommerce-review-order__form'
+ );
+ const submit = form.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+ const rating = form.querySelector( '.woocommerce-star-rating__input' );
+ const textarea = form.querySelector( 'textarea' );
+ rating.checked = true;
+ rating.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+
+ submit.click();
+ expect( window.fetch ).toHaveBeenCalledTimes( 1 );
+
+ // The dirty check alone would reopen the gate on this keystroke and
+ // advertise a button that the AJAX handler goes on refusing.
+ textarea.value = 'Still typing.';
+ textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) );
+ expect( submit.getAttribute( 'aria-disabled' ) ).toBe( 'true' );
+
+ // The row is edited, so the hint would name the wrong reason here.
+ expect(
+ form.querySelector( '.woocommerce-review-order__submit-hint' )
+ .hidden
+ ).toBe( true );
+ } );
+
+ test.each( [
+ [ 'ltr', '4' ],
+ [ 'rtl', '2' ],
+ ] )(
+ 'follows the visual star order for ArrowRight in %s',
+ ( direction, expected ) => {
+ document.body.innerHTML = `
+ <div class="woocommerce-star-rating" style="direction: ${ direction };">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="Perfect" />
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="4" data-label="Good" />
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="3" data-label="Average" />
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="2" data-label="Not that bad" />
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="1" data-label="Very poor" />
+ </div>
+ `;
+
+ initializeReviewOrder();
+
+ // DOM order is 5..1, and RTL flips the row again.
+ const third = document.querySelector(
+ '.woocommerce-star-rating__input[value="3"]'
+ );
+ third.checked = true;
+ third.focus();
+ third.dispatchEvent(
+ new window.KeyboardEvent( 'keydown', {
+ key: 'ArrowRight',
+ bubbles: true,
+ } )
+ );
+
+ expect(
+ document.querySelector(
+ '.woocommerce-star-rating__input:checked'
+ ).value
+ ).toBe( expected );
+ }
+ );
} );
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
index 73b38798ae0..7aa75c91ec1 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
@@ -817,6 +817,7 @@ class Endpoint {
'pending_moderation' => __( 'Thanks, your review is pending approval.', 'woocommerce' ),
'error' => __( 'Something went wrong, please try again.', 'woocommerce' ),
'rating_required' => __( 'Please rate this product before submitting your review.', 'woocommerce' ),
+ 'submit_hint' => __( 'Add or change a rating or review to submit.', 'woocommerce' ),
),
)
);
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Meta.php b/plugins/woocommerce/src/Internal/OrderReviews/Meta.php
index be3fc84755a..2f93b03dd47 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Meta.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Meta.php
@@ -10,6 +10,20 @@ use WC_Order;
*/
class Meta {
+ /**
+ * The meta line shown above the heading, parts joined by the separator.
+ *
+ * @param WC_Order $order Order being reviewed.
+ * @return string
+ */
+ public static function line_for_order( WC_Order $order ): string {
+ // Spaces inside the string: a locale that needs a full-width
+ // separator has to be able to drop them.
+ $separator = _x( ' · ', 'Review Order meta-line separator', 'woocommerce' );
+
+ return implode( $separator, self::parts_for_order( $order ) );
+ }
+
/**
* Build the meta-line parts shown above the heading on both the form and
* empty-state views (customer name, billing email, order #/date).
diff --git a/plugins/woocommerce/templates/order/customer-review-order-empty.php b/plugins/woocommerce/templates/order/customer-review-order-empty.php
index 54cb128806f..ad5ea988665 100644
--- a/plugins/woocommerce/templates/order/customer-review-order-empty.php
+++ b/plugins/woocommerce/templates/order/customer-review-order-empty.php
@@ -6,7 +6,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 10.8.0
+ * @version 11.3.0
*
* @var WC_Order $order Order being reviewed.
* @var int $reviewed_count Number of items reviewed on this order.
@@ -18,11 +18,11 @@ if ( ! $order instanceof WC_Order ) {
return;
}
-$meta_parts = \Automattic\WooCommerce\Internal\OrderReviews\Meta::parts_for_order( $order );
+$meta_line = \Automattic\WooCommerce\Internal\OrderReviews\Meta::line_for_order( $order );
?>
<div class="woocommerce-review-order woocommerce-review-order--empty">
<p class="woocommerce-breadcrumb woocommerce-review-order__meta">
- <?php echo esc_html( implode( ' · ', $meta_parts ) ); ?>
+ <?php echo esc_html( $meta_line ); ?>
</p>
<h1 class="woocommerce-review-order__empty-title">
diff --git a/plugins/woocommerce/templates/order/customer-review-order-row.php b/plugins/woocommerce/templates/order/customer-review-order-row.php
index b6a2927377e..40f47a7517d 100644
--- a/plugins/woocommerce/templates/order/customer-review-order-row.php
+++ b/plugins/woocommerce/templates/order/customer-review-order-row.php
@@ -6,7 +6,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 10.9.0
+ * @version 11.3.0
*
* @var WC_Order_Item_Product $item Order line item being rendered.
* @var WC_Product $product Product attached to the line item.
@@ -38,6 +38,8 @@ $variation_summary = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibili
$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_error_id = 'woocommerce-review-rating-error-' . $item_id;
+$row_status_id = 'woocommerce-review-status-' . $item_id;
$rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::render(
array(
@@ -68,6 +70,9 @@ $rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::rend
<?php endif; ?>
</p>
+ <?php // Not a live region: the script focuses the rating and the radios point here with `aria-describedby`. ?>
+ <p id="<?php echo esc_attr( $rating_error_id ); ?>" class="woocommerce-review-order__item-rating-error"></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. ?>
@@ -77,14 +82,9 @@ $rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::rend
<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 ); ?>" />
+ <?php // No required marker: a rating is only needed once the row has text, which `aria-invalid` conveys. ?>
<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' )
- );
- ?>
+ <?php esc_html_e( 'Your rating', 'woocommerce' ); ?>
</p>
<?php echo $rating_control; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- StarRating::render() returns escaped HTML. ?>
</div>
@@ -119,4 +119,6 @@ $rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::rend
*/
do_action( 'woocommerce_review_order_form_fields', $item, $product, $order, $row_index );
?>
+
+ <p id="<?php echo esc_attr( $row_status_id ); ?>" class="woocommerce-review-order__item-status" role="status"></p>
</li>
diff --git a/plugins/woocommerce/templates/order/customer-review-order.php b/plugins/woocommerce/templates/order/customer-review-order.php
index c25828516ee..139c920f944 100644
--- a/plugins/woocommerce/templates/order/customer-review-order.php
+++ b/plugins/woocommerce/templates/order/customer-review-order.php
@@ -17,7 +17,7 @@ if ( ! $order instanceof WC_Order ) {
return;
}
-$meta_parts = \Automattic\WooCommerce\Internal\OrderReviews\Meta::parts_for_order( $order );
+$meta_line = \Automattic\WooCommerce\Internal\OrderReviews\Meta::line_for_order( $order );
/**
* Filter the eligible items rendered on the Review Order page.
@@ -93,10 +93,11 @@ $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_
?>
<div class="woocommerce-review-order">
<p class="woocommerce-breadcrumb woocommerce-review-order__meta">
- <?php echo esc_html( implode( ' · ', $meta_parts ) ); ?>
+ <?php echo esc_html( $meta_line ); ?>
</p>
- <h1 class="woocommerce-review-order__title">
+ <?php // Focusable so dismissing the notice can hand focus back. ?>
+ <h1 class="woocommerce-review-order__title" tabindex="-1">
<?php esc_html_e( 'Review your order', 'woocommerce' ); ?>
</h1>
@@ -104,10 +105,6 @@ $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_
<?php esc_html_e( 'Loved something? Not so much? Share a quick review for what you bought. Feel free to skip any product.', 'woocommerce' ); ?>
</p>
- <p class="woocommerce-review-order__legend">
- <?php esc_html_e( '* Mandatory fields', 'woocommerce' ); ?>
- </p>
-
<?php if ( $skipped_count > 0 ) : ?>
<div
class="woocommerce-info woocommerce-review-order__notice"
@@ -179,8 +176,9 @@ $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_
</div>
</form>
+ <?php // Focusable so the script can move focus here once the form is hidden. ?>
<div class="woocommerce-review-order__success" hidden>
- <h1 class="woocommerce-review-order__empty-title">
+ <h1 class="woocommerce-review-order__empty-title" tabindex="-1">
<?php esc_html_e( 'Thank you for your reviews', 'woocommerce' ); ?>
</h1>
<p class="woocommerce-review-order__empty-body">
diff --git a/plugins/woocommerce/templates/order/star-rating.php b/plugins/woocommerce/templates/order/star-rating.php
index a82d4cba042..5eedfbf96bf 100644
--- a/plugins/woocommerce/templates/order/star-rating.php
+++ b/plugins/woocommerce/templates/order/star-rating.php
@@ -6,7 +6,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 10.8.0
+ * @version 11.3.0
*
* @var string $name Form field name (e.g. `reviews[123][rating]`).
* @var string $id_prefix Prefix used for unique radio ids.
@@ -27,7 +27,6 @@ $reversed = array_reverse( $labels, true );
class="woocommerce-star-rating"
role="radiogroup"
aria-labelledby="<?php echo esc_attr( $label_id ); ?>"
- aria-describedby="<?php echo esc_attr( $caption_id ); ?>"
>
<div class="woocommerce-star-rating__stars">
<?php foreach ( $reversed as $value => $label ) : ?>
@@ -69,9 +68,9 @@ $reversed = array_reverse( $labels, true );
<?php endforeach; ?>
</div>
+ <?php // Visual only: each radio's own label already carries this text. ?>
<span
id="<?php echo esc_attr( $caption_id ); ?>"
class="woocommerce-star-rating__caption"
- aria-live="polite"
><?php echo esc_html( $initial_caption ); ?></span>
</div>
diff --git a/plugins/woocommerce/tests/e2e/tests/order/review-order-page.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/review-order-page.spec.ts
index 16931f7bc30..a4dac0459cd 100644
--- a/plugins/woocommerce/tests/e2e/tests/order/review-order-page.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/order/review-order-page.spec.ts
@@ -625,5 +625,384 @@ test.describe(
await cleanupProducts( restApi, [ parentId ] );
}
} );
+
+ test( 'Star control — arrow keys follow the visual order and the fill tracks the selection', async ( {
+ page,
+ restApi,
+ } ) => {
+ const { order, productIds } = await seedCompletedOrder( restApi, [
+ { name: 'CRR Star Product' },
+ ] );
+
+ try {
+ await page.goto( reviewOrderUrl( order ) );
+
+ // Without this the icons fade over 100ms, and an assertion
+ // that a star is unfilled passes on the first sample of a
+ // fill that is still fading in.
+ await page.addStyleTag( {
+ content:
+ '.woocommerce-star-rating__icon { transition: none !important; }',
+ } );
+
+ const row = page
+ .locator( '.woocommerce-review-order__item' )
+ .nth( 0 );
+ const starInput = ( stars: number ) =>
+ row.locator(
+ `.woocommerce-star-rating__input[value="${ stars }"]`
+ );
+ const starLabel = ( stars: number ) =>
+ row.locator(
+ `.woocommerce-star-rating__input[value="${ stars }"] + label`
+ );
+ const checkedValue = () =>
+ row
+ .locator( '.woocommerce-star-rating__input:checked' )
+ .getAttribute( 'value' );
+ const iconOpacity = ( stars: number ) =>
+ starLabel( stars )
+ .locator( '.woocommerce-star-rating__icon' )
+ .evaluate( ( icon ) =>
+ parseFloat(
+ window.getComputedStyle( icon ).opacity
+ )
+ );
+ const expectFilled = async ( stars: number ) =>
+ expect( await iconOpacity( stars ) ).toBe( 1 );
+ const expectUnfilled = async ( stars: number ) =>
+ expect( await iconOpacity( stars ) ).toBeLessThan( 1 );
+
+ // A floor, not an equality: below 0.55 the unfilled
+ // star drops under the 3:1 of WCAG 1.4.11.
+ const resting = await iconOpacity( 1 );
+ expect( resting ).toBeGreaterThanOrEqual( 0.55 );
+ expect( resting ).toBeLessThan( 1 );
+
+ // Star markup is ordered 5..1, which the keys must not follow.
+ await starInput( 1 ).focus();
+ await page.keyboard.press( 'ArrowRight' );
+ expect( await checkedValue() ).toBe( '2' );
+
+ await page.keyboard.press( 'ArrowLeft' );
+ expect( await checkedValue() ).toBe( '1' );
+
+ await page.keyboard.press( 'End' );
+ expect( await checkedValue() ).toBe( '5' );
+
+ await page.keyboard.press( 'Home' );
+ expect( await checkedValue() ).toBe( '1' );
+
+ // A selection fills up to it and no further, and hovering a
+ // higher star must add no fill the customer did not pick.
+ await rateRow( row, 4 );
+ for ( const stars of [ 1, 2, 3, 4 ] ) {
+ await expectFilled( stars );
+ }
+ await expectUnfilled( 5 );
+
+ await starLabel( 5 ).hover();
+ for ( const stars of [ 1, 2, 3, 4 ] ) {
+ await expectFilled( stars );
+ }
+ await expectUnfilled( 5 );
+
+ // The fill still follows the next selection under that pointer.
+ await page.keyboard.press( 'End' );
+ expect( await checkedValue() ).toBe( '5' );
+ await expectFilled( 5 );
+ } finally {
+ await cleanupOrder( restApi, order.id );
+ await cleanupProducts( restApi, productIds );
+ }
+ } );
+
+ test( 'Reflow — the page fits a 640px and a 320px viewport and stays operable', async ( {
+ page,
+ restApi,
+ } ) => {
+ // A long unbroken token is what actually stresses reflow; a short
+ // name would pass on any layout.
+ const { order, productIds } = await seedCompletedOrder( restApi, [
+ { name: 'CRR Reflow Unterwasserfotografieausruestungstasche' },
+ ] );
+
+ try {
+ const row = page
+ .locator( '.woocommerce-review-order__item' )
+ .nth( 0 );
+
+ // Viewport emulation rather than real zoom: WCAG 1.4.10 states its
+ // target as 320 CSS px, the width a 1280px viewport reaches at 400%
+ // zoom, and 640px is the same at 200%.
+ for ( const width of [ 640, 320 ] ) {
+ await page.setViewportSize( { width, height: 512 } );
+ await page.goto( reviewOrderUrl( order ) );
+
+ // The success criterion itself: no horizontal page scrolling.
+ const pageOverflow = await page.evaluate(
+ () =>
+ document.documentElement.scrollWidth -
+ document.documentElement.clientWidth
+ );
+ expect(
+ pageOverflow,
+ `page scrolls horizontally at ${ width }px`
+ ).toBeLessThanOrEqual( 1 );
+
+ // Then which of our own elements would cause it. Measured per
+ // element because `scrollWidth` rounds to whole pixels and flakes
+ // on a fractional layout, and on both edges so the mirror failure
+ // in RTL is visible too.
+ const overflowing = await page
+ .locator( '.woocommerce-review-order' )
+ .evaluate( ( root ) => {
+ const bounds = root.getBoundingClientRect();
+ return Array.from( root.querySelectorAll( '*' ) )
+ .filter( ( el ) => {
+ // A non-rendered element reports a zero-size
+ // rect at the origin, which reads as overflow
+ // to the left of the container.
+ if ( ! el.getClientRects().length ) {
+ return false;
+ }
+ const box = el.getBoundingClientRect();
+ // 2px for sub-pixel headroom over the
+ // clipped radios, which paint nothing and
+ // sit exactly 1px out on `margin: -1px`.
+ return (
+ box.right > bounds.right + 2 ||
+ box.left < bounds.left - 2
+ );
+ } )
+ .map( ( el ) => {
+ const box = el.getBoundingClientRect();
+ const cls =
+ el.getAttribute( 'class' ) || '';
+ return `${ el.tagName }.${ cls } right${ (
+ box.right - bounds.right
+ ).toFixed( 1 ) } left${ (
+ box.left - bounds.left
+ ).toFixed( 1 ) }`;
+ } );
+ } );
+ expect(
+ overflowing,
+ `elements past the container at ${ width }px`
+ ).toEqual( [] );
+
+ await expect( row.locator( 'textarea' ) ).toBeVisible();
+ await expect(
+ page.locator( '.woocommerce-review-order__submit' )
+ ).toBeVisible();
+
+ // Still operable at this width.
+ await rateRow( row, 3 );
+ await expect(
+ row.locator( '.woocommerce-star-rating__input:checked' )
+ ).toHaveAttribute( 'value', '3' );
+ }
+ } finally {
+ await cleanupOrder( restApi, order.id );
+ await cleanupProducts( restApi, productIds );
+ }
+ } );
+
+ test( 'Keyboard — submitting moves focus into the thank-you view', async ( {
+ page,
+ restApi,
+ } ) => {
+ const { order, productIds } = await seedCompletedOrder( restApi, [
+ { name: 'CRR Focus Product' },
+ ] );
+
+ try {
+ await page.goto( reviewOrderUrl( order ) );
+
+ const row = page
+ .locator( '.woocommerce-review-order__item' )
+ .nth( 0 );
+ await rateRow( row, 4 );
+ await row
+ .locator( 'textarea' )
+ .fill( 'Submitted by keyboard.' );
+
+ const submit = page.locator(
+ '.woocommerce-review-order__submit'
+ );
+ await submit.focus();
+ await page.keyboard.press( 'Enter' );
+
+ await expect(
+ page.getByRole( 'heading', {
+ name: 'Thank you for your reviews',
+ } )
+ ).toBeVisible();
+
+ // The form and the focused button in it are hidden on success,
+ // so focus must move to something with a name.
+ await expect(
+ page.locator(
+ '.woocommerce-review-order__success .woocommerce-review-order__empty-title'
+ )
+ ).toBeFocused();
+ } finally {
+ await cleanupOrder( restApi, order.id );
+ await cleanupProducts( restApi, productIds );
+ }
+ } );
+
+ test( 'Keyboard — a text-only row sends focus to its rating and links the error', async ( {
+ page,
+ restApi,
+ } ) => {
+ const { order, productIds } = await seedCompletedOrder( restApi, [
+ { name: 'CRR Validation Product' },
+ { name: 'CRR Validation Product 2' },
+ ] );
+
+ try {
+ await page.goto( reviewOrderUrl( order ) );
+
+ const row = page
+ .locator( '.woocommerce-review-order__item' )
+ .nth( 0 );
+ await row
+ .locator( 'textarea' )
+ .fill( 'Text without a rating.' );
+ await page
+ .locator( '.woocommerce-review-order__submit' )
+ .click();
+
+ // On the radio that takes focus, not on the wrapper.
+ const lowest = row.locator(
+ '.woocommerce-star-rating__input[value="1"]'
+ );
+ await expect( lowest ).toHaveAttribute(
+ 'aria-invalid',
+ 'true'
+ );
+
+ const errorId = await row
+ .locator( '.woocommerce-review-order__item-rating-error' )
+ .getAttribute( 'id' );
+ expect(
+ await lowest.getAttribute( 'aria-describedby' )
+ ).toContain( errorId );
+
+ // Focus lands on the rating the customer has to fill in.
+ await expect( lowest ).toBeFocused();
+
+ // Script-moved focus after a pointer click does not match
+ // `:focus-visible`; the width alone proves nothing because it
+ // resolves to `medium` even with `outline-style: none`.
+ const outline = await row
+ .locator(
+ '.woocommerce-star-rating__input[value="1"] + label'
+ )
+ .evaluate( ( label ) => {
+ const style = window.getComputedStyle( label );
+ return {
+ style: style.outlineStyle,
+ width: parseFloat( style.outlineWidth ),
+ };
+ } );
+ expect( outline.style ).not.toBe( 'none' );
+ expect( outline.width ).toBeGreaterThan( 0 );
+
+ // `nearest` bottom-aligns the label, so the edge lands on the
+ // boundary itself and only a whole pixel of slack is real.
+ const expectInViewport = async (
+ scope: Locator,
+ selector: string,
+ height: number,
+ what: string
+ ) => {
+ const box = ( await scope
+ .locator( selector )
+ .boundingBox() )!;
+ expect(
+ box.y,
+ `${ what } above the fold at ${ height }px`
+ ).toBeGreaterThanOrEqual( -1 );
+ expect(
+ box.y + box.height,
+ `${ what } below the fold at ${ height }px`
+ ).toBeLessThanOrEqual( height + 1 );
+ };
+ const submitSecondRowAt = async ( size: {
+ width: number;
+ height: number;
+ } ) => {
+ await page.setViewportSize( size );
+ await page.goto( reviewOrderUrl( order ) );
+ const second = page
+ .locator( '.woocommerce-review-order__item' )
+ .nth( 1 );
+ await second
+ .locator( 'textarea' )
+ .fill( 'Text without a rating.' );
+ await page
+ .locator( '.woocommerce-review-order__submit' )
+ .click();
+ return second;
+ };
+ const errorSelector =
+ '.woocommerce-review-order__item-rating-error';
+ const starSelector =
+ '.woocommerce-star-rating__input[value="1"] + label';
+
+ // Stacked, the error sits above a 240px image column, which
+ // puts 364px between it and the stars. Both still fit here.
+ let second = await submitSecondRowAt( {
+ width: 390,
+ height: 664,
+ } );
+ await expectInViewport( second, errorSelector, 664, 'error' );
+ await expectInViewport( second, starSelector, 664, 'star' );
+
+ // 320x256 is 1280x1024 at the 400% zoom WCAG 1.4.10 names, and
+ // the pair cannot both fit: the ring wins, because it is the
+ // clipped radios' only indicator and `aria-describedby` still
+ // carries the message.
+ second = await submitSecondRowAt( { width: 320, height: 256 } );
+ await expectInViewport( second, starSelector, 256, 'star' );
+ } finally {
+ await cleanupOrder( restApi, order.id );
+ await cleanupProducts( restApi, productIds );
+ }
+ } );
+
+ test( 'Keyboard — dismissing the disabled-products notice keeps focus on the page', async ( {
+ page,
+ restApi,
+ } ) => {
+ const { order, productIds } = await seedCompletedOrder( restApi, [
+ { name: 'CRR Reviewable Product' },
+ { name: 'CRR Closed Product', reviews_allowed: false },
+ ] );
+
+ try {
+ await page.goto( reviewOrderUrl( order ) );
+
+ const notice = page.locator(
+ '.woocommerce-review-order__notice'
+ );
+ await expect( notice ).toBeVisible();
+
+ await notice
+ .locator( '.woocommerce-review-order__notice-dismiss' )
+ .click();
+
+ await expect( notice ).toBeHidden();
+ // Focus must not fall back to the document.
+ await expect(
+ page.locator( '.woocommerce-review-order__title' )
+ ).toBeFocused();
+ } finally {
+ await cleanupOrder( restApi, order.id );
+ await cleanupProducts( restApi, productIds );
+ }
+ } );
}
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
index 98c8196f694..5c6311cbb6a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
@@ -667,7 +667,7 @@ class EndpointTest extends WC_Unit_Test_Case {
}
/**
- * @testdox The wc-order-review script is localized with the four submission-outcome strings the frontend renders.
+ * @testdox The wc-order-review script is localized with the five strings the frontend renders.
*/
public function test_enqueued_script_localizes_the_submission_outcome_strings(): void {
$page_id = (int) wc_get_page_id( Endpoint::PAGE_KEY );
@@ -702,6 +702,7 @@ class EndpointTest extends WC_Unit_Test_Case {
'pending_moderation' => 'Thanks, your review is pending approval.',
'error' => 'Something went wrong, please try again.',
'rating_required' => 'Please rate this product before submitting your review.',
+ 'submit_hint' => 'Add or change a rating or review to submit.',
),
$payload['i18n'] ?? null,
'The frontend renders these strings verbatim, so the page must ship them unchanged.'
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/StarRatingTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/StarRatingTest.php
index 58be565de49..8217c4c4b57 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/StarRatingTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/StarRatingTest.php
@@ -33,7 +33,9 @@ class StarRatingTest extends WC_Unit_Test_Case {
$this->assertStringContainsString( 'role="radiogroup"', $html );
$this->assertStringContainsString( 'aria-labelledby="review-rating-label-42"', $html );
- $this->assertStringContainsString( 'aria-describedby="review-rating-42-caption"', $html );
+ // The caption is visual only; describing the group with it repeats
+ // what each radio's own label already announces.
+ $this->assertStringNotContainsString( 'aria-describedby', $html );
// Five inputs, each with a unique id.
foreach ( range( 1, 5 ) as $value ) {
@@ -42,7 +44,10 @@ class StarRatingTest extends WC_Unit_Test_Case {
}
$this->assertStringContainsString( 'name="reviews[42][rating]"', $html );
- $this->assertStringContainsString( 'aria-live="polite"', $html );
+
+ // Each radio's own label carries the caption text, so announcing the
+ // caption again on selection would duplicate it.
+ $this->assertStringNotContainsString( 'aria-live', $html );
}
/**