From 9c8f5aa39e36c327afd4bdf1ed49d9e83be4e93f Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Wed, 13 May 2026 12:41:01 +0100 Subject: [PATCH 01/11] feat(group-subs): polish invite-links UX in My Account --- .../class-group-subscription-invite.php | 40 ++++++++++ .../my-account/class-my-account-ui-v1.php | 4 +- .../v1/group-subscription-members.php | 79 +++++++++++-------- src/my-account/v1/_group-subscriptions.scss | 50 ++++++------ src/my-account/v1/group-subscriptions.js | 59 +++++++++----- 5 files changed, 154 insertions(+), 78 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php index 6796ec7599..9cf0e6344c 100644 --- a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php +++ b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php @@ -120,6 +120,46 @@ public static function get_expiration_time() { return apply_filters( 'newspack_group_subscription_invite_expiration_time', 30 * DAY_IN_SECONDS ); } + /** + * Get the expiration window as a human-readable label (e.g. "30 days", "1 hour"). + * + * Unlike core's human_time_diff(), this keeps the natural unit instead of rolling + * 30 days up to "1 month". + * + * @return string Localized label. + */ + public static function get_expiration_label() { + $seconds = (int) self::get_expiration_time(); + + if ( $seconds < HOUR_IN_SECONDS ) { + $minutes = max( 1, (int) round( $seconds / MINUTE_IN_SECONDS ) ); + /* translators: %d: number of minutes. */ + return sprintf( _n( '%d minute', '%d minutes', $minutes, 'newspack-plugin' ), $minutes ); + } + + if ( $seconds < DAY_IN_SECONDS ) { + $hours = max( 1, (int) round( $seconds / HOUR_IN_SECONDS ) ); + /* translators: %d: number of hours. */ + return sprintf( _n( '%d hour', '%d hours', $hours, 'newspack-plugin' ), $hours ); + } + + if ( $seconds < WEEK_IN_SECONDS ) { + $days = max( 1, (int) round( $seconds / DAY_IN_SECONDS ) ); + /* translators: %d: number of days. */ + return sprintf( _n( '%d day', '%d days', $days, 'newspack-plugin' ), $days ); + } + + if ( $seconds < MONTH_IN_SECONDS ) { + $weeks = max( 1, (int) round( $seconds / WEEK_IN_SECONDS ) ); + /* translators: %d: number of weeks. */ + return sprintf( _n( '%d week', '%d weeks', $weeks, 'newspack-plugin' ), $weeks ); + } + + $days = max( 1, (int) round( $seconds / DAY_IN_SECONDS ) ); + /* translators: %d: number of days. */ + return sprintf( _n( '%d day', '%d days', $days, 'newspack-plugin' ), $days ); + } + /** * Check if a group subscription invitation has expired. * Expiration timestamps are stored as an array map keyed by invite key. diff --git a/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php b/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php index ac69c49d29..d71caa802e 100644 --- a/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php +++ b/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php @@ -111,8 +111,8 @@ public static function enqueue_assets() { 'change_payment_method_title' => __( 'Change payment method', 'newspack-plugin' ), 'switch_subscription_title' => __( 'Change Subscription', 'newspack-plugin' ), 'invite_link_copied' => __( 'Invite link copied.', 'newspack-plugin' ), - 'invite_link_regenerated' => __( 'New invite link copied. Previous link is no longer valid.', 'newspack-plugin' ), - 'invite_link_disabled' => __( 'Invite link disabled.', 'newspack-plugin' ), + 'invite_link_regenerated' => __( 'New invite link copied. The old one no longer works.', 'newspack-plugin' ), + 'invite_link_disabled' => __( 'Invite link disabled. You can create a new link any time.', 'newspack-plugin' ), ], 'rest' => [ 'base_url' => get_rest_url(), diff --git a/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php b/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php index e7725069a6..fd71eeca8d 100644 --- a/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php +++ b/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php @@ -25,6 +25,7 @@ $invite_link = Group_Subscription_Invite::get_link_invite( $subscription, $current_user_id ); $invite_link_url = $invite_link ? Group_Subscription_Invite::get_link_invite_url( $subscription->get_id(), $current_user_id, $invite_link['key'] ) : ''; $is_at_limit = $member_limit > 0 && ( count( $members ) + count( $pending_invites ) ) >= $member_limit; +$active_tab = ( isset( $_GET['activeTab'] ) && 'invites' === $_GET['activeTab'] ) ? 'invites' : 'members'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
-
- + diff --git a/src/my-account/v1/_group-subscriptions.scss b/src/my-account/v1/_group-subscriptions.scss index 7440a32182..4a772f5eda 100644 --- a/src/my-account/v1/_group-subscriptions.scss +++ b/src/my-account/v1/_group-subscriptions.scss @@ -1,31 +1,20 @@ .newspack-ui .newspack-my-account__group_subscription { - &__tabs { - display: flex; - gap: 16px; - margin-bottom: 16px; - } - &__content { - .newspack-my-account__group_subscription__members, - .newspack-my-account__group_subscription__invites { - display: none; - } - &[data-active-tab="members"] { - .newspack-my-account__group_subscription__tabs--members { - font-weight: 600; - } - .newspack-my-account__group_subscription__members { - display: table; - } - } - &[data-active-tab="invites"] { - .newspack-my-account__group_subscription__tabs--invites { - font-weight: 600; - } - .newspack-my-account__group_subscription__invites { - display: table; - } + &__segmented-control { + align-items: flex-start; + margin-bottom: var(--newspack-ui-spacer-5); + + .newspack-ui__button { + display: inline-flex; + align-items: center; + gap: var(--newspack-ui-spacer-2); } } + + // Enter animation for invite-link controls revealed after the link is created. + &__entering { + animation: newspack-group-subscription-enter 125ms ease both; + } + &__members, &__invites { margin-top: var(--newspack-ui-spacer-5); @@ -46,3 +35,14 @@ } } } + +@keyframes newspack-group-subscription-enter { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/my-account/v1/group-subscriptions.js b/src/my-account/v1/group-subscriptions.js index ce36d60e23..30c469e04b 100644 --- a/src/my-account/v1/group-subscriptions.js +++ b/src/my-account/v1/group-subscriptions.js @@ -12,9 +12,6 @@ domReady( function () { return; } - // Look for the activeTab parameter in the URL and set the active tab accordingly. - const params = new URLSearchParams( window.location.search ); - const activeTab = params.get( 'activeTab' ) === 'invites' ? 'invites' : 'members'; const subId = parseInt( content.getAttribute( 'data-subscription-id' ), 10 ); const baseUrl = newspackMyAccountV1?.rest?.base_url; const namespace = newspackMyAccountV1?.rest?.namespaces?.group; @@ -23,22 +20,26 @@ domReady( function () { typeof newspackUI?.notices?.createNotice === 'function' ? newspackUI.notices.createNotice : ( msg, type ) => console.warn( '[group-subscriptions]', type, msg ); // eslint-disable-line no-console - if ( content ) { - content.setAttribute( 'data-active-tab', activeTab ); - } - // Handle tab switching. - const tabs = [ ...document.querySelectorAll( '.newspack-my-account__group_subscription__tabs a' ) ]; - tabs.forEach( tab => { - tab.addEventListener( 'click', event => { - event.preventDefault(); - if ( ! content ) { - return; - } - const tabName = event.currentTarget.getAttribute( 'data-tab' ); - content.setAttribute( 'data-active-tab', tabName ); + // Swap each tab badge between --outline (default) and --secondary (selected). + const segmentedControl = content.querySelector( '.newspack-my-account__group_subscription__segmented-control' ); + if ( segmentedControl ) { + const syncBadgeVariants = () => { + segmentedControl.querySelectorAll( '.newspack-ui__segmented-control__tabs > .newspack-ui__button' ).forEach( button => { + const badge = button.querySelector( '.newspack-ui__badge' ); + if ( ! badge ) { + return; + } + const isSelected = button.classList.contains( 'selected' ); + badge.classList.toggle( 'newspack-ui__badge--secondary', isSelected ); + badge.classList.toggle( 'newspack-ui__badge--outline', ! isSelected ); + } ); + }; + syncBadgeVariants(); + segmentedControl.querySelectorAll( '.newspack-ui__segmented-control__tabs > .newspack-ui__button' ).forEach( button => { + button.addEventListener( 'click', syncBadgeVariants ); } ); - } ); + } // Handle invite modal. const inviteModal = document.getElementById( 'newspack-my-account__group_subscription--invite-member' ); @@ -84,7 +85,14 @@ domReady( function () { const parent = button.closest( 'li' ); const el = parent || button; if ( show ) { + const wasHidden = el.classList.contains( 'hidden' ); el.classList.remove( 'hidden' ); + if ( wasHidden ) { + el.classList.add( 'newspack-my-account__group_subscription__entering' ); + el.addEventListener( 'animationend', () => el.classList.remove( 'newspack-my-account__group_subscription__entering' ), { + once: true, + } ); + } } else { el.classList.add( 'hidden' ); } @@ -104,12 +112,20 @@ domReady( function () { return false; } }; + // Minimum loading duration so the spinner reads as "system thinking" even when the API is instant. + const MIN_LOADING_MS = 500; + const waitForMinLoading = start => { + const elapsed = performance.now() - start; + return elapsed < MIN_LOADING_MS ? new Promise( resolve => setTimeout( resolve, MIN_LOADING_MS - elapsed ) ) : Promise.resolve(); + }; + const generateLink = async e => { const el = e.currentTarget; el.classList.add( 'newspack-ui__button--loading' ); el.setAttribute( 'aria-busy', 'true' ); el.setAttribute( 'disabled', '' ); const errorText = e.currentTarget.getAttribute( 'data-error-text' ); + const loadingStart = performance.now(); try { const response = await fetch( restUrl, { method: 'POST', @@ -121,6 +137,7 @@ domReady( function () { body: JSON.stringify( { subscription_id: subId } ), } ); const data = await response.json(); + await waitForMinLoading( loadingStart ); if ( ! response.ok || ! data || ! data.url ) { const message = ( data && data.message ) || errorText; showSnackbar( message, 'error' ); @@ -128,13 +145,14 @@ domReady( function () { } if ( await copyToClipboard( data.url ) ) { const message = !! content.getAttribute( 'data-invite-link' ) - ? newspackMyAccountV1?.labels?.invite_link_regenerated || 'New invite link copied. Previous link is no longer valid.' + ? newspackMyAccountV1?.labels?.invite_link_regenerated || 'New invite link copied. The old one no longer works.' : newspackMyAccountV1?.labels?.invite_link_copied || 'Invite link copied.'; showSnackbar( message ); } content.setAttribute( 'data-invite-link', data.url ); afterInviteLink( true ); } catch ( error ) { + await waitForMinLoading( loadingStart ); showSnackbar( errorText, 'error' ); } finally { el.classList.remove( 'newspack-ui__button--loading' ); @@ -149,6 +167,7 @@ domReady( function () { el.setAttribute( 'aria-busy', 'true' ); el.setAttribute( 'disabled', '' ); const errorText = e.currentTarget.getAttribute( 'data-error-text' ); + const loadingStart = performance.now(); try { const response = await fetch( restUrl, { method: 'DELETE', @@ -160,15 +179,17 @@ domReady( function () { body: JSON.stringify( { subscription_id: subId } ), } ); const data = await response.json(); + await waitForMinLoading( loadingStart ); if ( ! response.ok ) { const message = ( data && data.message ) || errorText; showSnackbar( message, 'error' ); return; } - showSnackbar( newspackMyAccountV1?.labels?.invite_link_disabled || 'Invite link disabled.' ); + showSnackbar( newspackMyAccountV1?.labels?.invite_link_disabled || 'Invite link disabled. You can create a new link any time.' ); content.removeAttribute( 'data-invite-link' ); afterInviteLink( false ); } catch ( error ) { + await waitForMinLoading( loadingStart ); showSnackbar( errorText, 'error' ); } finally { el.classList.remove( 'newspack-ui__button--loading' ); From 9c75b1dad0eebf3b7ba2fa93ebaa65a5880721cc Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Wed, 13 May 2026 12:45:35 +0100 Subject: [PATCH 02/11] fix(group-subs): align empty-state row height in invite tables --- src/my-account/v1/_group-subscriptions.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/my-account/v1/_group-subscriptions.scss b/src/my-account/v1/_group-subscriptions.scss index 4a772f5eda..2616f136b6 100644 --- a/src/my-account/v1/_group-subscriptions.scss +++ b/src/my-account/v1/_group-subscriptions.scss @@ -18,6 +18,14 @@ &__members, &__invites { margin-top: var(--newspack-ui-spacer-5); + + // Match populated row height for empty-state cells. + td[colspan]::after { + content: ''; + display: inline-block; + vertical-align: middle; + min-height: var(--newspack-ui-spacer-7); + } } &__members--actions, &__invites--actions { @@ -29,7 +37,7 @@ content: ''; display: inline-block; vertical-align: middle; - min-height: 36px; + min-height: var(--newspack-ui-spacer-7); } } } From a3e5a60b3a576f17f8e3d6be52f611d31fb09c36 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Wed, 13 May 2026 13:06:58 +0100 Subject: [PATCH 03/11] fix(group-subs): address review feedback on invite-links polish --- .../class-group-subscription-invite.php | 35 ++++++------- .../v1/group-subscription-members.php | 2 +- src/my-account/v1/group-subscriptions.js | 5 +- .../content-gate/group-subscriptions.php | 51 +++++++++++++++++++ 4 files changed, 69 insertions(+), 24 deletions(-) diff --git a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php index 9cf0e6344c..2ba64c719e 100644 --- a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php +++ b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php @@ -123,41 +123,36 @@ public static function get_expiration_time() { /** * Get the expiration window as a human-readable label (e.g. "30 days", "1 hour"). * - * Unlike core's human_time_diff(), this keeps the natural unit instead of rolling - * 30 days up to "1 month". - * * @return string Localized label. */ public static function get_expiration_label() { $seconds = (int) self::get_expiration_time(); - if ( $seconds < HOUR_IN_SECONDS ) { - $minutes = max( 1, (int) round( $seconds / MINUTE_IN_SECONDS ) ); - /* translators: %d: number of minutes. */ - return sprintf( _n( '%d minute', '%d minutes', $minutes, 'newspack-plugin' ), $minutes ); + if ( $seconds <= 0 ) { + $seconds = 1; } - if ( $seconds < DAY_IN_SECONDS ) { - $hours = max( 1, (int) round( $seconds / HOUR_IN_SECONDS ) ); - /* translators: %d: number of hours. */ - return sprintf( _n( '%d hour', '%d hours', $hours, 'newspack-plugin' ), $hours ); + if ( $seconds >= WEEK_IN_SECONDS && 0 === $seconds % WEEK_IN_SECONDS ) { + $weeks = (int) ( $seconds / WEEK_IN_SECONDS ); + /* translators: %d: number of weeks. */ + return sprintf( _n( '%d week', '%d weeks', $weeks, 'newspack-plugin' ), $weeks ); } - if ( $seconds < WEEK_IN_SECONDS ) { - $days = max( 1, (int) round( $seconds / DAY_IN_SECONDS ) ); + if ( $seconds >= DAY_IN_SECONDS && 0 === $seconds % DAY_IN_SECONDS ) { + $days = (int) ( $seconds / DAY_IN_SECONDS ); /* translators: %d: number of days. */ return sprintf( _n( '%d day', '%d days', $days, 'newspack-plugin' ), $days ); } - if ( $seconds < MONTH_IN_SECONDS ) { - $weeks = max( 1, (int) round( $seconds / WEEK_IN_SECONDS ) ); - /* translators: %d: number of weeks. */ - return sprintf( _n( '%d week', '%d weeks', $weeks, 'newspack-plugin' ), $weeks ); + if ( $seconds >= HOUR_IN_SECONDS && 0 === $seconds % HOUR_IN_SECONDS ) { + $hours = (int) ( $seconds / HOUR_IN_SECONDS ); + /* translators: %d: number of hours. */ + return sprintf( _n( '%d hour', '%d hours', $hours, 'newspack-plugin' ), $hours ); } - $days = max( 1, (int) round( $seconds / DAY_IN_SECONDS ) ); - /* translators: %d: number of days. */ - return sprintf( _n( '%d day', '%d days', $days, 'newspack-plugin' ), $days ); + $minutes = max( 1, (int) ceil( $seconds / MINUTE_IN_SECONDS ) ); + /* translators: %d: number of minutes. */ + return sprintf( _n( '%d minute', '%d minutes', $minutes, 'newspack-plugin' ), $minutes ); } /** diff --git a/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php b/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php index fd71eeca8d..cd0e7fa88d 100644 --- a/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php +++ b/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php @@ -25,7 +25,7 @@ $invite_link = Group_Subscription_Invite::get_link_invite( $subscription, $current_user_id ); $invite_link_url = $invite_link ? Group_Subscription_Invite::get_link_invite_url( $subscription->get_id(), $current_user_id, $invite_link['key'] ) : ''; $is_at_limit = $member_limit > 0 && ( count( $members ) + count( $pending_invites ) ) >= $member_limit; -$active_tab = ( isset( $_GET['activeTab'] ) && 'invites' === $_GET['activeTab'] ) ? 'invites' : 'members'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended +$active_tab = ( isset( $_GET['activeTab'] ) && 'invites' === sanitize_key( wp_unslash( $_GET['activeTab'] ) ) ) ? 'invites' : 'members'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
-
+
@@ -215,7 +215,7 @@
-
+
From 011adcc71f36ac4cec4e343448456c9a1b469f11 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Wed, 13 May 2026 13:42:06 +0100 Subject: [PATCH 06/11] test(group-subs): guard expiration-label tests with try/finally --- .../content-gate/group-subscriptions.php | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/unit-tests/content-gate/group-subscriptions.php b/tests/unit-tests/content-gate/group-subscriptions.php index 495fd955e6..e89a5c9dd5 100644 --- a/tests/unit-tests/content-gate/group-subscriptions.php +++ b/tests/unit-tests/content-gate/group-subscriptions.php @@ -2460,13 +2460,15 @@ public function test_get_expiration_label_uses_largest_exact_unit() { }; add_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); - $this->assertSame( - $expected, - Group_Subscription_Invite::get_expiration_label(), - "Expected '{$expected}' for case: {$label}" - ); - - remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); + try { + $this->assertSame( + $expected, + Group_Subscription_Invite::get_expiration_label(), + "Expected '{$expected}' for case: {$label}" + ); + } finally { + remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); + } } } @@ -2479,8 +2481,10 @@ public function test_get_expiration_label_floor_is_one_minute() { }; add_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); - $this->assertSame( '1 minute', Group_Subscription_Invite::get_expiration_label() ); - - remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); + try { + $this->assertSame( '1 minute', Group_Subscription_Invite::get_expiration_label() ); + } finally { + remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); + } } } From a397b8999779a2be41409cc46fd5755234ac7e6f Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Wed, 13 May 2026 14:09:42 +0100 Subject: [PATCH 07/11] fix(group-subs): reduced-motion support and performance.now fallback --- src/my-account/v1/_group-subscriptions.scss | 4 ++++ src/my-account/v1/group-subscriptions.js | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/my-account/v1/_group-subscriptions.scss b/src/my-account/v1/_group-subscriptions.scss index 2616f136b6..fb88ed4f1e 100644 --- a/src/my-account/v1/_group-subscriptions.scss +++ b/src/my-account/v1/_group-subscriptions.scss @@ -13,6 +13,10 @@ // Enter animation for invite-link controls revealed after the link is created. &__entering { animation: newspack-group-subscription-enter 125ms ease both; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } } &__members, diff --git a/src/my-account/v1/group-subscriptions.js b/src/my-account/v1/group-subscriptions.js index 4d6fb49abc..b7fd3c1f55 100644 --- a/src/my-account/v1/group-subscriptions.js +++ b/src/my-account/v1/group-subscriptions.js @@ -113,8 +113,9 @@ domReady( function () { }; // Minimum loading duration so the spinner reads as "system thinking" even when the API is instant. const MIN_LOADING_MS = 500; + const now = () => ( typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now() ); const waitForMinLoading = start => { - const elapsed = performance.now() - start; + const elapsed = now() - start; return elapsed < MIN_LOADING_MS ? new Promise( resolve => setTimeout( resolve, MIN_LOADING_MS - elapsed ) ) : Promise.resolve(); }; @@ -124,7 +125,7 @@ domReady( function () { el.setAttribute( 'aria-busy', 'true' ); el.setAttribute( 'disabled', '' ); const errorText = e.currentTarget.getAttribute( 'data-error-text' ); - const loadingStart = performance.now(); + const loadingStart = now(); try { const response = await fetch( restUrl, { method: 'POST', @@ -166,7 +167,7 @@ domReady( function () { el.setAttribute( 'aria-busy', 'true' ); el.setAttribute( 'disabled', '' ); const errorText = e.currentTarget.getAttribute( 'data-error-text' ); - const loadingStart = performance.now(); + const loadingStart = now(); try { const response = await fetch( restUrl, { method: 'DELETE', From fa24d1a15d59c5f55f8d5bb42f7e70706c65f036 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Wed, 13 May 2026 19:24:14 +0100 Subject: [PATCH 08/11] fix(ui): add ARIA tab semantics and keyboard nav to segmented-control --- src/newspack-ui/js/segmented-control/index.js | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/newspack-ui/js/segmented-control/index.js b/src/newspack-ui/js/segmented-control/index.js index 01df55cf13..7b70798df9 100644 --- a/src/newspack-ui/js/segmented-control/index.js +++ b/src/newspack-ui/js/segmented-control/index.js @@ -68,6 +68,18 @@ domReady( function () { element.dispatchEvent( new CustomEvent( 'content-selected', { detail: selectedContent } ) ); }; + const isTablist = header && header.getAttribute( 'role' ) === 'tablist'; + const updateAria = activeIndex => { + if ( ! isTablist ) { + return; + } + tab_headers.forEach( ( t, j ) => { + const isActive = j === activeIndex; + t.setAttribute( 'aria-selected', isActive ? 'true' : 'false' ); + t.setAttribute( 'tabindex', isActive ? '0' : '-1' ); + } ); + }; + tab_headers.forEach( ( tab, i ) => { if ( tab_contents.length === 0 ) { return; @@ -84,13 +96,37 @@ domReady( function () { if ( tab.classList.contains( 'selected' ) ) { select_content( i ); + updateAria( i ); } tab.addEventListener( 'click', function () { tab_headers.forEach( t => t.classList.remove( 'selected' ) ); this.classList.add( 'selected' ); select_content( i ); + updateAria( i ); } ); + + if ( isTablist ) { + tab.addEventListener( 'keydown', function ( ev ) { + const last = tab_headers.length - 1; + let next = -1; + if ( ev.key === 'ArrowRight' ) { + next = i === last ? 0 : i + 1; + } else if ( ev.key === 'ArrowLeft' ) { + next = i === 0 ? last : i - 1; + } else if ( ev.key === 'Home' ) { + next = 0; + } else if ( ev.key === 'End' ) { + next = last; + } + if ( next < 0 ) { + return; + } + ev.preventDefault(); + tab_headers[ next ].focus(); + tab_headers[ next ].click(); + } ); + } } ); } From ea441fc891e3d2b4e502498349c913c25b8553e4 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Wed, 13 May 2026 19:24:33 +0100 Subject: [PATCH 09/11] fix(group-subs): address invite-links review feedback --- .../my-account/class-my-account-ui-v1.php | 2 + .../v1/group-subscription-members.php | 55 ++++++------ src/my-account/v1/_group-subscriptions.scss | 8 ++ src/my-account/v1/group-subscriptions.js | 86 +++++++++++++++++-- 4 files changed, 120 insertions(+), 31 deletions(-) diff --git a/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php b/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php index d71caa802e..5a709c2572 100644 --- a/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php +++ b/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php @@ -112,6 +112,8 @@ public static function enqueue_assets() { 'switch_subscription_title' => __( 'Change Subscription', 'newspack-plugin' ), 'invite_link_copied' => __( 'Invite link copied.', 'newspack-plugin' ), 'invite_link_regenerated' => __( 'New invite link copied. The old one no longer works.', 'newspack-plugin' ), + 'invite_link_copy_failed' => __( 'Couldn\'t copy the invite link to your clipboard. Copy it manually:', 'newspack-plugin' ), + 'dismiss' => __( 'Dismiss', 'newspack-plugin' ), 'invite_link_disabled' => __( 'Invite link disabled. You can create a new link any time.', 'newspack-plugin' ), ], 'rest' => [ diff --git a/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php b/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php index 49db73d679..a5aa0bedd5 100644 --- a/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php +++ b/includes/plugins/woocommerce/my-account/templates/v1/group-subscription-members.php @@ -125,32 +125,39 @@ @@ -337,7 +344,7 @@

- +

@@ -361,7 +368,7 @@

- +

diff --git a/src/my-account/v1/_group-subscriptions.scss b/src/my-account/v1/_group-subscriptions.scss index fb88ed4f1e..4326260e7b 100644 --- a/src/my-account/v1/_group-subscriptions.scss +++ b/src/my-account/v1/_group-subscriptions.scss @@ -31,6 +31,14 @@ min-height: var(--newspack-ui-spacer-7); } } + &__invite-link__manual-copy { + width: 100%; + margin: var(--newspack-ui-spacer-2) 0; + padding: var(--newspack-ui-spacer-1) var(--newspack-ui-spacer-2); + font-family: monospace; + user-select: all; + } + &__members--actions, &__invites--actions { &--manager { diff --git a/src/my-account/v1/group-subscriptions.js b/src/my-account/v1/group-subscriptions.js index b7fd3c1f55..19b5196876 100644 --- a/src/my-account/v1/group-subscriptions.js +++ b/src/my-account/v1/group-subscriptions.js @@ -88,9 +88,13 @@ domReady( function () { el.classList.remove( 'hidden' ); if ( wasHidden ) { el.classList.add( 'newspack-my-account__group_subscription__entering' ); - el.addEventListener( 'animationend', () => el.classList.remove( 'newspack-my-account__group_subscription__entering' ), { - once: true, - } ); + if ( parseFloat( getComputedStyle( el ).animationDuration ) > 0 ) { + el.addEventListener( 'animationend', () => el.classList.remove( 'newspack-my-account__group_subscription__entering' ), { + once: true, + } ); + } else { + el.classList.remove( 'newspack-my-account__group_subscription__entering' ); + } } } else { el.classList.add( 'hidden' ); @@ -108,9 +112,72 @@ domReady( function () { await navigator.clipboard.writeText( text ); return true; } catch ( e ) { - return false; + // Legacy fallback for insecure contexts / blocked clipboard permission. + try { + const textarea = document.createElement( 'textarea' ); + textarea.value = text; + textarea.setAttribute( 'readonly', '' ); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild( textarea ); + textarea.select(); + const ok = document.execCommand( 'copy' ); + document.body.removeChild( textarea ); + return ok; + } catch ( e2 ) { + return false; + } } }; + + const showCopyFailureNotice = url => { + let wrapper = document.querySelector( '.newspack-ui' ); + if ( ! wrapper ) { + wrapper = document.createElement( 'div' ); + wrapper.classList.add( 'newspack-ui' ); + document.body.appendChild( wrapper ); + } + let snackbar = wrapper.querySelector( '.newspack-ui__snackbar--top-right' ); + if ( ! snackbar ) { + snackbar = document.createElement( 'div' ); + snackbar.classList.add( 'newspack-ui__snackbar', 'newspack-ui__snackbar--top-right' ); + wrapper.appendChild( snackbar ); + } + const item = document.createElement( 'div' ); + item.classList.add( 'newspack-ui__snackbar__item', 'newspack-ui__snackbar__item--error' ); + item.setAttribute( 'data-autohide', 'false' ); + + const itemContent = document.createElement( 'div' ); + itemContent.classList.add( 'newspack-ui__snackbar__content' ); + + const msg = document.createElement( 'div' ); + msg.textContent = + newspackMyAccountV1?.labels?.invite_link_copy_failed || "Couldn't copy the invite link to your clipboard. Copy it manually:"; + itemContent.appendChild( msg ); + + const linkField = document.createElement( 'input' ); + linkField.type = 'text'; + linkField.readOnly = true; + linkField.value = url; + linkField.classList.add( 'newspack-my-account__group_subscription__invite-link__manual-copy' ); + linkField.addEventListener( 'focus', () => linkField.select() ); + itemContent.appendChild( linkField ); + + const close = document.createElement( 'button' ); + close.type = 'button'; + close.classList.add( 'newspack-ui__button', 'newspack-ui__button--ghost', 'newspack-ui__button--small' ); + close.textContent = newspackMyAccountV1?.labels?.dismiss || 'Dismiss'; + close.addEventListener( 'click', () => { + item.classList.remove( 'active' ); + setTimeout( () => item.remove(), 250 ); + } ); + itemContent.appendChild( close ); + + item.appendChild( itemContent ); + snackbar.appendChild( item ); + requestAnimationFrame( () => item.classList.add( 'active' ) ); + linkField.focus(); + }; // Minimum loading duration so the spinner reads as "system thinking" even when the API is instant. const MIN_LOADING_MS = 500; const now = () => ( typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now() ); @@ -143,14 +210,17 @@ domReady( function () { showSnackbar( message, 'error' ); return; } + const isRegenerate = !! content.getAttribute( 'data-invite-link' ); + content.setAttribute( 'data-invite-link', data.url ); + afterInviteLink( true ); if ( await copyToClipboard( data.url ) ) { - const message = !! content.getAttribute( 'data-invite-link' ) + const message = isRegenerate ? newspackMyAccountV1?.labels?.invite_link_regenerated || 'New invite link copied. The old one no longer works.' : newspackMyAccountV1?.labels?.invite_link_copied || 'Invite link copied.'; showSnackbar( message ); + } else { + showCopyFailureNotice( data.url ); } - content.setAttribute( 'data-invite-link', data.url ); - afterInviteLink( true ); } catch ( error ) { await waitForMinLoading( loadingStart ); showSnackbar( errorText, 'error' ); @@ -205,6 +275,8 @@ domReady( function () { if ( inviteLink ) { if ( await copyToClipboard( inviteLink ) ) { showSnackbar( newspackMyAccountV1?.labels?.invite_link_copied || 'Invite link copied.' ); + } else { + showCopyFailureNotice( inviteLink ); } } else { generateLink( e ); From 8ea711d4c9417eab261f453efc1a3d2d0e70fbc1 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Wed, 13 May 2026 19:24:41 +0100 Subject: [PATCH 10/11] test(group-subs): cover negative and i18n cases for expiration label --- .../content-gate/group-subscriptions.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit-tests/content-gate/group-subscriptions.php b/tests/unit-tests/content-gate/group-subscriptions.php index e89a5c9dd5..f3b031777e 100644 --- a/tests/unit-tests/content-gate/group-subscriptions.php +++ b/tests/unit-tests/content-gate/group-subscriptions.php @@ -2487,4 +2487,36 @@ public function test_get_expiration_label_floor_is_one_minute() { remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); } } + + /** + * Negative expiration times are coerced to the "1 minute" floor. + */ + public function test_get_expiration_label_floors_negative_values() { + $callback = function () { + return -100; + }; + add_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); + + try { + $this->assertSame( '1 minute', Group_Subscription_Invite::get_expiration_label() ); + } finally { + remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); + } + } + + /** + * Large counts are formatted via number_format_i18n() (thousands separator under the active locale). + */ + public function test_get_expiration_label_uses_number_format_i18n() { + $callback = function () { + return 1000 * DAY_IN_SECONDS; + }; + add_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); + + try { + $this->assertSame( '1,000 days', Group_Subscription_Invite::get_expiration_label() ); + } finally { + remove_filter( 'newspack_group_subscription_invite_expiration_time', $callback ); + } + } } From 99d9171ef1d542df047d26d39952538aa41265c6 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Thu, 14 May 2026 09:47:04 +0100 Subject: [PATCH 11/11] refactor(group-subs): simplify clipboard failure to a plain snackbar --- .../my-account/class-my-account-ui-v1.php | 1 - src/my-account/v1/_group-subscriptions.scss | 8 --- src/my-account/v1/group-subscriptions.js | 54 ++----------------- 3 files changed, 5 insertions(+), 58 deletions(-) diff --git a/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php b/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php index 5a709c2572..97133d38b0 100644 --- a/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php +++ b/includes/plugins/woocommerce/my-account/class-my-account-ui-v1.php @@ -113,7 +113,6 @@ public static function enqueue_assets() { 'invite_link_copied' => __( 'Invite link copied.', 'newspack-plugin' ), 'invite_link_regenerated' => __( 'New invite link copied. The old one no longer works.', 'newspack-plugin' ), 'invite_link_copy_failed' => __( 'Couldn\'t copy the invite link to your clipboard. Copy it manually:', 'newspack-plugin' ), - 'dismiss' => __( 'Dismiss', 'newspack-plugin' ), 'invite_link_disabled' => __( 'Invite link disabled. You can create a new link any time.', 'newspack-plugin' ), ], 'rest' => [ diff --git a/src/my-account/v1/_group-subscriptions.scss b/src/my-account/v1/_group-subscriptions.scss index 4326260e7b..fb88ed4f1e 100644 --- a/src/my-account/v1/_group-subscriptions.scss +++ b/src/my-account/v1/_group-subscriptions.scss @@ -31,14 +31,6 @@ min-height: var(--newspack-ui-spacer-7); } } - &__invite-link__manual-copy { - width: 100%; - margin: var(--newspack-ui-spacer-2) 0; - padding: var(--newspack-ui-spacer-1) var(--newspack-ui-spacer-2); - font-family: monospace; - user-select: all; - } - &__members--actions, &__invites--actions { &--manager { diff --git a/src/my-account/v1/group-subscriptions.js b/src/my-account/v1/group-subscriptions.js index 19b5196876..3030d3be3d 100644 --- a/src/my-account/v1/group-subscriptions.js +++ b/src/my-account/v1/group-subscriptions.js @@ -130,53 +130,9 @@ domReady( function () { } }; - const showCopyFailureNotice = url => { - let wrapper = document.querySelector( '.newspack-ui' ); - if ( ! wrapper ) { - wrapper = document.createElement( 'div' ); - wrapper.classList.add( 'newspack-ui' ); - document.body.appendChild( wrapper ); - } - let snackbar = wrapper.querySelector( '.newspack-ui__snackbar--top-right' ); - if ( ! snackbar ) { - snackbar = document.createElement( 'div' ); - snackbar.classList.add( 'newspack-ui__snackbar', 'newspack-ui__snackbar--top-right' ); - wrapper.appendChild( snackbar ); - } - const item = document.createElement( 'div' ); - item.classList.add( 'newspack-ui__snackbar__item', 'newspack-ui__snackbar__item--error' ); - item.setAttribute( 'data-autohide', 'false' ); - - const itemContent = document.createElement( 'div' ); - itemContent.classList.add( 'newspack-ui__snackbar__content' ); - - const msg = document.createElement( 'div' ); - msg.textContent = - newspackMyAccountV1?.labels?.invite_link_copy_failed || "Couldn't copy the invite link to your clipboard. Copy it manually:"; - itemContent.appendChild( msg ); - - const linkField = document.createElement( 'input' ); - linkField.type = 'text'; - linkField.readOnly = true; - linkField.value = url; - linkField.classList.add( 'newspack-my-account__group_subscription__invite-link__manual-copy' ); - linkField.addEventListener( 'focus', () => linkField.select() ); - itemContent.appendChild( linkField ); - - const close = document.createElement( 'button' ); - close.type = 'button'; - close.classList.add( 'newspack-ui__button', 'newspack-ui__button--ghost', 'newspack-ui__button--small' ); - close.textContent = newspackMyAccountV1?.labels?.dismiss || 'Dismiss'; - close.addEventListener( 'click', () => { - item.classList.remove( 'active' ); - setTimeout( () => item.remove(), 250 ); - } ); - itemContent.appendChild( close ); - - item.appendChild( itemContent ); - snackbar.appendChild( item ); - requestAnimationFrame( () => item.classList.add( 'active' ) ); - linkField.focus(); + const copyFailedMessage = url => { + const text = newspackMyAccountV1?.labels?.invite_link_copy_failed || "Couldn't copy the invite link to your clipboard. Copy it manually:"; + return `${ text } ${ url }`; }; // Minimum loading duration so the spinner reads as "system thinking" even when the API is instant. const MIN_LOADING_MS = 500; @@ -219,7 +175,7 @@ domReady( function () { : newspackMyAccountV1?.labels?.invite_link_copied || 'Invite link copied.'; showSnackbar( message ); } else { - showCopyFailureNotice( data.url ); + showSnackbar( copyFailedMessage( data.url ), 'error' ); } } catch ( error ) { await waitForMinLoading( loadingStart ); @@ -276,7 +232,7 @@ domReady( function () { if ( await copyToClipboard( inviteLink ) ) { showSnackbar( newspackMyAccountV1?.labels?.invite_link_copied || 'Invite link copied.' ); } else { - showCopyFailureNotice( inviteLink ); + showSnackbar( copyFailedMessage( inviteLink ), 'error' ); } } else { generateLink( e );