From 0627d27ae8167e5fd35169438b256a6fa368bb32 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Tue, 8 Sep 2026 14:39:20 +1000 Subject: [PATCH 1/7] feat(checkout): CHECKOUT-10399 Add first skeleton loading attempt --- .../core/src/app/payment/PaymentForm.test.tsx | 30 ++++++++ .../paymentMethod/PaymentMethodList.tsx | 77 ++++++++++++------- .../components/_components.scss | 1 + .../checkout/_loading-skeleton.scss | 42 ++++++++++ .../LoadingSkeleton/PaymentMethodSkeleton.tsx | 21 +++++ packages/ui/src/form/LoadingSkeleton/index.ts | 1 + packages/ui/src/form/index.ts | 1 + packages/ui/src/index.ts | 2 + packages/ui/src/loading/LazyContainer.tsx | 37 +++++---- .../ui/src/loading/LoadingOverlay.test.tsx | 45 +++++++++++ packages/ui/src/loading/LoadingOverlay.tsx | 8 +- .../ui/src/loading/LoadingSkeletonContext.ts | 6 ++ packages/ui/src/loading/index.ts | 1 + 13 files changed, 229 insertions(+), 43 deletions(-) create mode 100644 packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss create mode 100644 packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx create mode 100644 packages/ui/src/loading/LoadingSkeletonContext.ts diff --git a/packages/core/src/app/payment/PaymentForm.test.tsx b/packages/core/src/app/payment/PaymentForm.test.tsx index 5fc6a68c25..6aef2a9551 100644 --- a/packages/core/src/app/payment/PaymentForm.test.tsx +++ b/packages/core/src/app/payment/PaymentForm.test.tsx @@ -141,6 +141,36 @@ describe('PaymentForm', () => { expect(radios[1]).not.toBeChecked(); }); + it('does not render the loading overlay while a payment method is initializing in enhancedThemeV1', () => { + enhancedThemeV1 = true; + + render(); + + expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(2); + }); + + it('keeps payment methods unselectable while a payment method is initializing in enhancedThemeV1', () => { + enhancedThemeV1 = true; + + const onMethodSelect = jest.fn(); + + render( + , + ); + + const radios = screen.getAllByRole('radio'); + + fireEvent.click(radios[1]); + + expect(onMethodSelect).not.toHaveBeenCalled(); + expect(radios[1]).not.toBeChecked(); + }); + it('renders terms and conditions field if copy is provided', () => { const textAcceptTerms = 'Accept terms'; diff --git a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx index 8f2618790a..4ac6762824 100644 --- a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx +++ b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx @@ -2,8 +2,14 @@ import { type PaymentMethod } from '@bigcommerce/checkout-sdk'; import { find, noop } from 'lodash'; import React, { type FunctionComponent, memo, useCallback, useMemo } from 'react'; -import { useCheckout, useLocale } from '@bigcommerce/checkout/contexts'; -import { Checklist, ChecklistItem, LoadingOverlay } from '@bigcommerce/checkout/ui'; +import { useCheckout, useLocale, useThemeContext } from '@bigcommerce/checkout/contexts'; +import { + Checklist, + ChecklistItem, + LoadingOverlay, + LoadingSkeletonContext, + PaymentMethodSkeleton, +} from '@bigcommerce/checkout/ui'; import { connectFormik, type ConnectFormikProps } from '../../common/form'; @@ -23,6 +29,8 @@ export interface PaymentMethodListProps { onUnhandledError?(error: Error): void; } +const paymentMethodSkeleton = ; + function getPaymentMethodFromListValue(methods: PaymentMethod[], value: string): PaymentMethod { const { gatewayId: gateway, methodId: id } = parseUniquePaymentMethodId(value); const method = gateway ? find(methods, { gateway, id }) : find(methods, { id }); @@ -47,6 +55,7 @@ const PaymentMethodList: FunctionComponent< }) => { const { language } = useLocale(); const { selectedState: config } = useCheckout(({ data }) => data.getConfig()); + const { enhancedThemeV1 } = useThemeContext(); const chequeMethod = find(methods, { id: 'cheque' }); const chequeDisabledReason = usePoMethodDisabledReason(chequeMethod); @@ -81,37 +90,49 @@ const PaymentMethodList: FunctionComponent< [methods, onSelect], ); + const checklist = ( + + {methods.map((method) => { + const value = getUniquePaymentMethodId(method.id, method.gateway); + + return ( + + ); + })} + + ); + return ( <>
{titleText}
- - - {methods.map((method) => { - const value = getUniquePaymentMethodId(method.id, method.gateway); - - return ( - - ); - })} - - + {enhancedThemeV1 ? ( + // enhancedThemeV1 keeps the method list visible while a method + // initializes: no spinner veil, and the initializing method's + // fields render as a skeleton (items stay read-only via + // isReadOnly until initialization finishes). + + {checklist} + + ) : ( + + {checklist} + + )} ); }; diff --git a/packages/core/src/scss/enhancedThemeV1/components/_components.scss b/packages/core/src/scss/enhancedThemeV1/components/_components.scss index 33bbb0d3ab..60c0d6e744 100644 --- a/packages/core/src/scss/enhancedThemeV1/components/_components.scss +++ b/packages/core/src/scss/enhancedThemeV1/components/_components.scss @@ -11,3 +11,4 @@ @import "checkout/payment-billing"; @import "checkout/paymentProvider"; @import "checkout/loading-dots"; +@import "checkout/loading-skeleton"; diff --git a/packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss b/packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss new file mode 100644 index 0000000000..6c5cb7f1c7 --- /dev/null +++ b/packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss @@ -0,0 +1,42 @@ +// ============================================================================= +// PAYMENT METHOD LOADING SKELETON +// ============================================================================= + +// Shown inside an initializing payment method instead of the loading spinner. +// Only rendered under enhancedThemeV1 (see PaymentMethodList). +.payment-method-skeleton { + @include enhancedThemeV1 { + padding: spacing("half") 0; + + div { + animation: $loading-skeleton-grey-box-animation; + background: $loading-skeleton-grey-box-color; + border-radius: $global-radius; + display: block; + } + + .payment-method-skeleton--label { + height: remCalc(10px); + margin-bottom: spacing("single"); + width: 25%; + } + + .payment-method-skeleton--fields { + animation: none; + background: none; + border-radius: 0; + display: flex; + gap: spacing("single"); + } + + .payment-method-skeleton--field-large { + flex: 3; + height: $floating-label-input-height; + } + + .payment-method-skeleton--field-small { + flex: 1; + height: $floating-label-input-height; + } + } +} diff --git a/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx b/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx new file mode 100644 index 0000000000..3ac3164d54 --- /dev/null +++ b/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx @@ -0,0 +1,21 @@ +import React, { type FunctionComponent } from 'react'; + +export const PaymentMethodSkeleton: FunctionComponent = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+); diff --git a/packages/ui/src/form/LoadingSkeleton/index.ts b/packages/ui/src/form/LoadingSkeleton/index.ts index 894f46c6dc..98acd97d07 100644 --- a/packages/ui/src/form/LoadingSkeleton/index.ts +++ b/packages/ui/src/form/LoadingSkeleton/index.ts @@ -4,3 +4,4 @@ export { default as CheckoutPageSkeleton } from './CheckoutPageSkeleton'; export { default as OrderConfirmationPageSkeleton } from './OrderConfirmationPageSkeleton'; export { default as WalletButtonsContainerSkeleton } from './WalletButtonContainerSkeleton'; export { CartSummarySkeleton } from './CartSummarySkeleton'; +export { PaymentMethodSkeleton } from './PaymentMethodSkeleton'; diff --git a/packages/ui/src/form/index.ts b/packages/ui/src/form/index.ts index 4d28b4023d..52902fc7c4 100644 --- a/packages/ui/src/form/index.ts +++ b/packages/ui/src/form/index.ts @@ -20,6 +20,7 @@ export { CheckoutPageSkeleton, ChecklistSkeleton, OrderConfirmationPageSkeleton, + PaymentMethodSkeleton, WalletButtonsContainerSkeleton, } from './LoadingSkeleton'; export { Label } from './Label'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index affb59a2c2..9336d3fdba 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -27,6 +27,7 @@ export { CheckboxInput, ChecklistSkeleton, OrderConfirmationPageSkeleton, + PaymentMethodSkeleton, Fieldset, Form, FormField, @@ -110,6 +111,7 @@ export { LoadingDots, LoadingNotification, LoadingOverlay, + LoadingSkeletonContext, LoadingSpinner, } from './loading'; export { diff --git a/packages/ui/src/loading/LazyContainer.tsx b/packages/ui/src/loading/LazyContainer.tsx index db4c03d261..8a50cde8b9 100644 --- a/packages/ui/src/loading/LazyContainer.tsx +++ b/packages/ui/src/loading/LazyContainer.tsx @@ -1,10 +1,11 @@ -import React, { type FunctionComponent, type ReactNode, Suspense } from 'react'; +import React, { type FunctionComponent, type ReactNode, Suspense, useContext } from 'react'; import { ErrorBoundary } from '@bigcommerce/checkout/error-handling-utils'; import { TranslatedString } from '@bigcommerce/checkout/locale'; import './LazyContainer.scss'; +import { LoadingSkeletonContext } from './LoadingSkeletonContext'; import LoadingSpinner from './LoadingSpinner'; export interface LazyContainerProps { @@ -14,17 +15,27 @@ export interface LazyContainerProps { const filterError = (error: Error) => error.name === 'ChunkLoadError'; -const LazyContainer: FunctionComponent = ({ loadingSkeleton, children }) => ( - - -
- } - filter={filterError} - > - }>{children} - -); +const LazyContainer: FunctionComponent = ({ loadingSkeleton, children }) => { + const loadingSkeletonFallback = useContext(LoadingSkeletonContext); + + return ( + + +
+ } + filter={filterError} + > + + } + > + {children} + + + ); +}; export default LazyContainer; diff --git a/packages/ui/src/loading/LoadingOverlay.test.tsx b/packages/ui/src/loading/LoadingOverlay.test.tsx index c75bda4d63..e6a139a5b6 100644 --- a/packages/ui/src/loading/LoadingOverlay.test.tsx +++ b/packages/ui/src/loading/LoadingOverlay.test.tsx @@ -4,6 +4,7 @@ import '@testing-library/jest-dom'; import { render, screen } from '@bigcommerce/checkout/test-utils'; import LoadingOverlay from './LoadingOverlay'; +import { LoadingSkeletonContext } from './LoadingSkeletonContext'; describe('LoadingOverlay', () => { it('should render the loading overlay', () => { @@ -51,4 +52,48 @@ describe('LoadingOverlay', () => { expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); }); + + it('renders the skeleton provided via LoadingSkeletonContext instead of the spinner when hiding content', () => { + render( + }> + +
Content
+
+
, + ); + + expect(screen.getByTestId('context-skeleton')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.getByText('Content')).not.toBeVisible(); + }); + + it('prefers the loadingSkeleton prop over the LoadingSkeletonContext skeleton', () => { + render( + }> + } + > +
Content
+
+
, + ); + + expect(screen.getByTestId('prop-skeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('context-skeleton')).not.toBeInTheDocument(); + }); + + it('does not use the LoadingSkeletonContext skeleton for the overlay variant', () => { + render( + }> + +
Content
+
+
, + ); + + expect(screen.getByTestId('loading-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('context-skeleton')).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/loading/LoadingOverlay.tsx b/packages/ui/src/loading/LoadingOverlay.tsx index 230468d2ce..951eae2a34 100644 --- a/packages/ui/src/loading/LoadingOverlay.tsx +++ b/packages/ui/src/loading/LoadingOverlay.tsx @@ -1,5 +1,6 @@ -import React, { type FunctionComponent, type ReactNode } from 'react'; +import React, { type FunctionComponent, type ReactNode, useContext } from 'react'; +import { LoadingSkeletonContext } from './LoadingSkeletonContext'; import LoadingSpinner from './LoadingSpinner'; export interface LoadingOverlayProps { @@ -17,7 +18,10 @@ const LoadingOverlay: FunctionComponent = ({ unmountContentWhenLoading, isLoading, }) => { - const loadingUI = loadingSkeleton || ; + const loadingSkeletonFallback = useContext(LoadingSkeletonContext); + const loadingUI = loadingSkeleton || loadingSkeletonFallback || ( + + ); if (hideContentWhenLoading || unmountContentWhenLoading) { return ( diff --git a/packages/ui/src/loading/LoadingSkeletonContext.ts b/packages/ui/src/loading/LoadingSkeletonContext.ts new file mode 100644 index 0000000000..49088ecb3e --- /dev/null +++ b/packages/ui/src/loading/LoadingSkeletonContext.ts @@ -0,0 +1,6 @@ +import { createContext, type ReactNode } from 'react'; + +// Lets an ancestor (e.g. the enhancedThemeV1 payment method list) swap the +// default spinner rendered by LoadingOverlay and LazyContainer for a skeleton, +// without threading a prop through every payment integration package. +export const LoadingSkeletonContext = createContext(undefined); diff --git a/packages/ui/src/loading/index.ts b/packages/ui/src/loading/index.ts index a2668d2de4..2ac4abd0f1 100644 --- a/packages/ui/src/loading/index.ts +++ b/packages/ui/src/loading/index.ts @@ -2,4 +2,5 @@ export { default as LazyContainer } from './LazyContainer'; export { LoadingDots } from './LoadingDots'; export { default as LoadingNotification } from './LoadingNotification'; export { default as LoadingOverlay } from './LoadingOverlay'; +export { LoadingSkeletonContext } from './LoadingSkeletonContext'; export { default as LoadingSpinner } from './LoadingSpinner'; From df8bc5eac2330ced41257b2085b91f03544b49a4 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Tue, 8 Sep 2026 16:14:53 +1000 Subject: [PATCH 2/7] fix(checkout): CHECKOUT-10399 Design tweaks --- .../components/checkout/_checklist.scss | 11 ++++ .../checkout/_loading-skeleton.scss | 60 +++++++++++++++---- .../ChecklistItemInput/ChecklistItemInput.tsx | 10 +++- .../LoadingSkeleton/PaymentMethodSkeleton.tsx | 26 +++++--- 4 files changed, 85 insertions(+), 22 deletions(-) diff --git a/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss b/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss index 3012012dab..f7072883cc 100644 --- a/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss +++ b/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss @@ -71,5 +71,16 @@ & ~ .form-label::before { border: 2px $input-border-style $input-border-color; } + + // Payment methods stay read-only (not disabled) while initializing, + // so they remain focusable — but should still look non-interactive. + &.form-checklist-checkbox--disabled ~ .form-label { + cursor: not-allowed; + + &::before, + &::after { + opacity: $checkRadio-disabled-opacity; + } + } } } diff --git a/packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss b/packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss index 6c5cb7f1c7..c2c06779cc 100644 --- a/packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss +++ b/packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss @@ -2,41 +2,75 @@ // PAYMENT METHOD LOADING SKELETON // ============================================================================= +// The shared grey/lightest-grey pulse ($loading-skeleton-grey-box-animation) +// is tuned for a white background. A selected payment method under +// enhancedThemeV1 sits on $color-highlightLightest (a pale lavender), which +// is close enough in luminance to make that pulse nearly invisible, so this +// skeleton uses its own darker pair of greys instead. +@keyframes payment-method-skeleton-pulse { + 0%, 100% { + background-color: color("greys", "lighter"); + } + + 50% { + background-color: mix(color("greys", "light"), color("greys", "medium"), 50%); + } +} + // Shown inside an initializing payment method instead of the loading spinner. // Only rendered under enhancedThemeV1 (see PaymentMethodList). +// Each row mirrors a row of the real credit card fieldset (e.g. card number + +// expiration, or name on card + CVV): a label sits directly above its own +// field so the skeleton lines up with the fields it is standing in for. .payment-method-skeleton { @include enhancedThemeV1 { padding: spacing("half") 0; div { - animation: $loading-skeleton-grey-box-animation; - background: $loading-skeleton-grey-box-color; + animation: payment-method-skeleton-pulse 4000ms infinite cubic-bezier(0.13, 0.615, 0.315, 0.915); + background: color("greys", "lighter"); border-radius: $global-radius; display: block; } - .payment-method-skeleton--label { - height: remCalc(10px); - margin-bottom: spacing("single"); - width: 25%; - } - - .payment-method-skeleton--fields { + .payment-method-skeleton--row { animation: none; background: none; border-radius: 0; display: flex; gap: spacing("single"); + + &:not(:last-child) { + margin-bottom: spacing("single"); + } } - .payment-method-skeleton--field-large { - flex: 3; - height: $floating-label-input-height; + .payment-method-skeleton--field-group { + animation: none; + background: none; + border-radius: 0; } - .payment-method-skeleton--field-small { + .payment-method-skeleton--field-group-large { flex: 1; + } + + .payment-method-skeleton--field-group-small { + // Roughly matches the real CVV/expiry field's width (~105px) + // instead of stretching to fill the remaining row width. + flex: 0 0 remCalc(105px); + } + + .payment-method-skeleton--label { + height: remCalc(10px); + margin-bottom: spacing("half"); + width: 40%; + } + + .payment-method-skeleton--field-large, + .payment-method-skeleton--field-small { height: $floating-label-input-height; + width: 100%; } } } diff --git a/packages/ui/src/form/ChecklistItemInput/ChecklistItemInput.tsx b/packages/ui/src/form/ChecklistItemInput/ChecklistItemInput.tsx index cde5055f2c..2b5fe7d426 100644 --- a/packages/ui/src/form/ChecklistItemInput/ChecklistItemInput.tsx +++ b/packages/ui/src/form/ChecklistItemInput/ChecklistItemInput.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import React, { type FunctionComponent, type InputHTMLAttributes } from 'react'; import { Input } from '../Input'; @@ -11,13 +12,20 @@ const ChecklistItemInput: FunctionComponent = ({ id, isSelected, children, + disabled, + 'aria-disabled': ariaDisabled, ...props }) => ( <> diff --git a/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx b/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx index 3ac3164d54..2f8f64eb4d 100644 --- a/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx +++ b/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx @@ -7,15 +7,25 @@ export const PaymentMethodSkeleton: FunctionComponent = () => ( data-test="payment-method-skeleton" role="status" > -
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
); From 6e833e749a82b162d8617797433390b2ab8252e2 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Thu, 10 Sep 2026 13:59:33 +1000 Subject: [PATCH 3/7] chore(checkout): CHECKOUT-10399 A small refactor --- .../paymentMethod/PaymentMethodList.tsx | 26 +++++++++++++++++-- .../components/checkout/_checklist.scss | 3 ++- .../ChecklistItemInput/ChecklistItemInput.tsx | 10 +------ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx index 4ac6762824..8c326c1af6 100644 --- a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx +++ b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx @@ -156,8 +156,10 @@ const PaymentMethodListItem: FunctionComponent = ({ onUnhandledError, value, }) => { + const { enhancedThemeV1 } = useThemeContext(); + const renderPaymentMethod = useMemo(() => { - return ( + const paymentMethod = ( = ({ onUnhandledError={onUnhandledError || noop} /> ); - }, [isEmbedded, isUsingMultiShipping, method, onUnhandledError]); + + // enhancedThemeV1 has no spinner veil over the list, so stand in a + // skeleton (from LoadingSkeletonContext) for the selected method's + // fields while it initializes. Doing it here works for every + // integration, whether or not it forwards isInitializing to its own + // LoadingOverlay. + return enhancedThemeV1 ? ( + + {paymentMethod} + + ) : ( + paymentMethod + ); + }, [ + enhancedThemeV1, + isEmbedded, + isInitializingPayment, + isUsingMultiShipping, + method, + onUnhandledError, + ]); const renderPaymentMethodTitle = useCallback( (isSelected: boolean) => ( diff --git a/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss b/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss index f7072883cc..ba3157773f 100644 --- a/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss +++ b/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss @@ -74,7 +74,8 @@ // Payment methods stay read-only (not disabled) while initializing, // so they remain focusable — but should still look non-interactive. - &.form-checklist-checkbox--disabled ~ .form-label { + &:disabled ~ .form-label, + &[aria-disabled="true"] ~ .form-label { cursor: not-allowed; &::before, diff --git a/packages/ui/src/form/ChecklistItemInput/ChecklistItemInput.tsx b/packages/ui/src/form/ChecklistItemInput/ChecklistItemInput.tsx index 2b5fe7d426..cde5055f2c 100644 --- a/packages/ui/src/form/ChecklistItemInput/ChecklistItemInput.tsx +++ b/packages/ui/src/form/ChecklistItemInput/ChecklistItemInput.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import React, { type FunctionComponent, type InputHTMLAttributes } from 'react'; import { Input } from '../Input'; @@ -12,20 +11,13 @@ const ChecklistItemInput: FunctionComponent = ({ id, isSelected, children, - disabled, - 'aria-disabled': ariaDisabled, ...props }) => ( <> From 7a3ad80cb30225aa49775b57868b07828bddddb9 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Thu, 10 Sep 2026 14:22:00 +1000 Subject: [PATCH 4/7] chore(checkout): CHECKOUT-10399 Remove skeleton context --- .../core/src/app/payment/PaymentForm.test.tsx | 8 ++++ .../paymentMethod/PaymentMethodList.tsx | 24 +++++----- packages/ui/src/index.ts | 1 - packages/ui/src/loading/LazyContainer.tsx | 37 ++++++--------- .../ui/src/loading/LoadingOverlay.test.tsx | 45 ------------------- packages/ui/src/loading/LoadingOverlay.tsx | 8 +--- .../ui/src/loading/LoadingSkeletonContext.ts | 6 --- packages/ui/src/loading/index.ts | 1 - 8 files changed, 33 insertions(+), 97 deletions(-) delete mode 100644 packages/ui/src/loading/LoadingSkeletonContext.ts diff --git a/packages/core/src/app/payment/PaymentForm.test.tsx b/packages/core/src/app/payment/PaymentForm.test.tsx index 6aef2a9551..d0503f9224 100644 --- a/packages/core/src/app/payment/PaymentForm.test.tsx +++ b/packages/core/src/app/payment/PaymentForm.test.tsx @@ -171,6 +171,14 @@ describe('PaymentForm', () => { expect(radios[1]).not.toBeChecked(); }); + it('renders the payment method skeleton for the initializing method in enhancedThemeV1', () => { + enhancedThemeV1 = true; + + render(); + + expect(screen.getByTestId('payment-method-skeleton')).toBeInTheDocument(); + }); + it('renders terms and conditions field if copy is provided', () => { const textAcceptTerms = 'Accept terms'; diff --git a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx index 8c326c1af6..97e3fedbab 100644 --- a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx +++ b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx @@ -7,7 +7,6 @@ import { Checklist, ChecklistItem, LoadingOverlay, - LoadingSkeletonContext, PaymentMethodSkeleton, } from '@bigcommerce/checkout/ui'; @@ -121,13 +120,9 @@ const PaymentMethodList: FunctionComponent< {titleText}
{enhancedThemeV1 ? ( - // enhancedThemeV1 keeps the method list visible while a method - // initializes: no spinner veil, and the initializing method's - // fields render as a skeleton (items stay read-only via - // isReadOnly until initialization finishes). - - {checklist} - + // enhancedThemeV1 keeps the method list visible while a method initializes: + // no spinner veil. The skeleton is applied per-item in PaymentMethodListItem. + checklist ) : ( {checklist} @@ -168,13 +163,14 @@ const PaymentMethodListItem: FunctionComponent = ({ /> ); - // enhancedThemeV1 has no spinner veil over the list, so stand in a - // skeleton (from LoadingSkeletonContext) for the selected method's - // fields while it initializes. Doing it here works for every - // integration, whether or not it forwards isInitializing to its own - // LoadingOverlay. + // enhancedThemeV1 has no spinner veil over the list, so stand in a skeleton for the + // selected method's fields while it initializes. return enhancedThemeV1 ? ( - + {paymentMethod} ) : ( diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9336d3fdba..b1b14a35cb 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -111,7 +111,6 @@ export { LoadingDots, LoadingNotification, LoadingOverlay, - LoadingSkeletonContext, LoadingSpinner, } from './loading'; export { diff --git a/packages/ui/src/loading/LazyContainer.tsx b/packages/ui/src/loading/LazyContainer.tsx index 8a50cde8b9..db4c03d261 100644 --- a/packages/ui/src/loading/LazyContainer.tsx +++ b/packages/ui/src/loading/LazyContainer.tsx @@ -1,11 +1,10 @@ -import React, { type FunctionComponent, type ReactNode, Suspense, useContext } from 'react'; +import React, { type FunctionComponent, type ReactNode, Suspense } from 'react'; import { ErrorBoundary } from '@bigcommerce/checkout/error-handling-utils'; import { TranslatedString } from '@bigcommerce/checkout/locale'; import './LazyContainer.scss'; -import { LoadingSkeletonContext } from './LoadingSkeletonContext'; import LoadingSpinner from './LoadingSpinner'; export interface LazyContainerProps { @@ -15,27 +14,17 @@ export interface LazyContainerProps { const filterError = (error: Error) => error.name === 'ChunkLoadError'; -const LazyContainer: FunctionComponent = ({ loadingSkeleton, children }) => { - const loadingSkeletonFallback = useContext(LoadingSkeletonContext); - - return ( - - -
- } - filter={filterError} - > - - } - > - {children} - - - ); -}; +const LazyContainer: FunctionComponent = ({ loadingSkeleton, children }) => ( + + +
+ } + filter={filterError} + > + }>{children} + +); export default LazyContainer; diff --git a/packages/ui/src/loading/LoadingOverlay.test.tsx b/packages/ui/src/loading/LoadingOverlay.test.tsx index e6a139a5b6..c75bda4d63 100644 --- a/packages/ui/src/loading/LoadingOverlay.test.tsx +++ b/packages/ui/src/loading/LoadingOverlay.test.tsx @@ -4,7 +4,6 @@ import '@testing-library/jest-dom'; import { render, screen } from '@bigcommerce/checkout/test-utils'; import LoadingOverlay from './LoadingOverlay'; -import { LoadingSkeletonContext } from './LoadingSkeletonContext'; describe('LoadingOverlay', () => { it('should render the loading overlay', () => { @@ -52,48 +51,4 @@ describe('LoadingOverlay', () => { expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); }); - - it('renders the skeleton provided via LoadingSkeletonContext instead of the spinner when hiding content', () => { - render( - }> - -
Content
-
-
, - ); - - expect(screen.getByTestId('context-skeleton')).toBeInTheDocument(); - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - expect(screen.getByText('Content')).not.toBeVisible(); - }); - - it('prefers the loadingSkeleton prop over the LoadingSkeletonContext skeleton', () => { - render( - }> - } - > -
Content
-
-
, - ); - - expect(screen.getByTestId('prop-skeleton')).toBeInTheDocument(); - expect(screen.queryByTestId('context-skeleton')).not.toBeInTheDocument(); - }); - - it('does not use the LoadingSkeletonContext skeleton for the overlay variant', () => { - render( - }> - -
Content
-
-
, - ); - - expect(screen.getByTestId('loading-overlay')).toBeInTheDocument(); - expect(screen.queryByTestId('context-skeleton')).not.toBeInTheDocument(); - }); }); diff --git a/packages/ui/src/loading/LoadingOverlay.tsx b/packages/ui/src/loading/LoadingOverlay.tsx index 951eae2a34..230468d2ce 100644 --- a/packages/ui/src/loading/LoadingOverlay.tsx +++ b/packages/ui/src/loading/LoadingOverlay.tsx @@ -1,6 +1,5 @@ -import React, { type FunctionComponent, type ReactNode, useContext } from 'react'; +import React, { type FunctionComponent, type ReactNode } from 'react'; -import { LoadingSkeletonContext } from './LoadingSkeletonContext'; import LoadingSpinner from './LoadingSpinner'; export interface LoadingOverlayProps { @@ -18,10 +17,7 @@ const LoadingOverlay: FunctionComponent = ({ unmountContentWhenLoading, isLoading, }) => { - const loadingSkeletonFallback = useContext(LoadingSkeletonContext); - const loadingUI = loadingSkeleton || loadingSkeletonFallback || ( - - ); + const loadingUI = loadingSkeleton || ; if (hideContentWhenLoading || unmountContentWhenLoading) { return ( diff --git a/packages/ui/src/loading/LoadingSkeletonContext.ts b/packages/ui/src/loading/LoadingSkeletonContext.ts deleted file mode 100644 index 49088ecb3e..0000000000 --- a/packages/ui/src/loading/LoadingSkeletonContext.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createContext, type ReactNode } from 'react'; - -// Lets an ancestor (e.g. the enhancedThemeV1 payment method list) swap the -// default spinner rendered by LoadingOverlay and LazyContainer for a skeleton, -// without threading a prop through every payment integration package. -export const LoadingSkeletonContext = createContext(undefined); diff --git a/packages/ui/src/loading/index.ts b/packages/ui/src/loading/index.ts index 2ac4abd0f1..a2668d2de4 100644 --- a/packages/ui/src/loading/index.ts +++ b/packages/ui/src/loading/index.ts @@ -2,5 +2,4 @@ export { default as LazyContainer } from './LazyContainer'; export { LoadingDots } from './LoadingDots'; export { default as LoadingNotification } from './LoadingNotification'; export { default as LoadingOverlay } from './LoadingOverlay'; -export { LoadingSkeletonContext } from './LoadingSkeletonContext'; export { default as LoadingSpinner } from './LoadingSpinner'; From 428a671b9135b32f8e1fefa430c8123b46c40c8e Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Thu, 10 Sep 2026 14:39:20 +1000 Subject: [PATCH 5/7] chore(checkout): CHECKOUT-10399 Code cleanup --- .../payment/paymentMethod/PaymentMethodList.tsx | 8 +------- .../enhancedThemeV1/components/_components.scss | 2 +- .../components/checkout/_checklist.scss | 3 +-- ...leton.scss => _payment-loading-skeleton.scss} | 16 ---------------- 4 files changed, 3 insertions(+), 26 deletions(-) rename packages/core/src/scss/enhancedThemeV1/components/checkout/{_loading-skeleton.scss => _payment-loading-skeleton.scss} (59%) diff --git a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx index 97e3fedbab..b528996ba1 100644 --- a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx +++ b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx @@ -28,8 +28,6 @@ export interface PaymentMethodListProps { onUnhandledError?(error: Error): void; } -const paymentMethodSkeleton = ; - function getPaymentMethodFromListValue(methods: PaymentMethod[], value: string): PaymentMethod { const { gatewayId: gateway, methodId: id } = parseUniquePaymentMethodId(value); const method = gateway ? find(methods, { gateway, id }) : find(methods, { id }); @@ -120,8 +118,6 @@ const PaymentMethodList: FunctionComponent< {titleText}
{enhancedThemeV1 ? ( - // enhancedThemeV1 keeps the method list visible while a method initializes: - // no spinner veil. The skeleton is applied per-item in PaymentMethodListItem. checklist ) : ( @@ -163,13 +159,11 @@ const PaymentMethodListItem: FunctionComponent = ({ /> ); - // enhancedThemeV1 has no spinner veil over the list, so stand in a skeleton for the - // selected method's fields while it initializes. return enhancedThemeV1 ? ( } > {paymentMethod} diff --git a/packages/core/src/scss/enhancedThemeV1/components/_components.scss b/packages/core/src/scss/enhancedThemeV1/components/_components.scss index 60c0d6e744..e6e287b76b 100644 --- a/packages/core/src/scss/enhancedThemeV1/components/_components.scss +++ b/packages/core/src/scss/enhancedThemeV1/components/_components.scss @@ -11,4 +11,4 @@ @import "checkout/payment-billing"; @import "checkout/paymentProvider"; @import "checkout/loading-dots"; -@import "checkout/loading-skeleton"; +@import "checkout/payment-loading-skeleton"; diff --git a/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss b/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss index ba3157773f..f1e0190085 100644 --- a/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss +++ b/packages/core/src/scss/enhancedThemeV1/components/checkout/_checklist.scss @@ -72,8 +72,7 @@ border: 2px $input-border-style $input-border-color; } - // Payment methods stay read-only (not disabled) while initializing, - // so they remain focusable — but should still look non-interactive. + // payments radio buttons should look non-interactive while initializing &:disabled ~ .form-label, &[aria-disabled="true"] ~ .form-label { cursor: not-allowed; diff --git a/packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss b/packages/core/src/scss/enhancedThemeV1/components/checkout/_payment-loading-skeleton.scss similarity index 59% rename from packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss rename to packages/core/src/scss/enhancedThemeV1/components/checkout/_payment-loading-skeleton.scss index c2c06779cc..4ca1846cc0 100644 --- a/packages/core/src/scss/enhancedThemeV1/components/checkout/_loading-skeleton.scss +++ b/packages/core/src/scss/enhancedThemeV1/components/checkout/_payment-loading-skeleton.scss @@ -1,12 +1,3 @@ -// ============================================================================= -// PAYMENT METHOD LOADING SKELETON -// ============================================================================= - -// The shared grey/lightest-grey pulse ($loading-skeleton-grey-box-animation) -// is tuned for a white background. A selected payment method under -// enhancedThemeV1 sits on $color-highlightLightest (a pale lavender), which -// is close enough in luminance to make that pulse nearly invisible, so this -// skeleton uses its own darker pair of greys instead. @keyframes payment-method-skeleton-pulse { 0%, 100% { background-color: color("greys", "lighter"); @@ -17,11 +8,6 @@ } } -// Shown inside an initializing payment method instead of the loading spinner. -// Only rendered under enhancedThemeV1 (see PaymentMethodList). -// Each row mirrors a row of the real credit card fieldset (e.g. card number + -// expiration, or name on card + CVV): a label sits directly above its own -// field so the skeleton lines up with the fields it is standing in for. .payment-method-skeleton { @include enhancedThemeV1 { padding: spacing("half") 0; @@ -56,8 +42,6 @@ } .payment-method-skeleton--field-group-small { - // Roughly matches the real CVV/expiry field's width (~105px) - // instead of stretching to fill the remaining row width. flex: 0 0 remCalc(105px); } From 0b7990384643e34b067200a7d7aa71a218f02c67 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Thu, 10 Sep 2026 14:54:46 +1000 Subject: [PATCH 6/7] chore(checkout): CHECKOUT-10399 Use generic skeleton --- .../checkout/_payment-loading-skeleton.scss | 40 ------------------- .../LoadingSkeleton/PaymentMethodSkeleton.tsx | 30 ++------------ 2 files changed, 4 insertions(+), 66 deletions(-) diff --git a/packages/core/src/scss/enhancedThemeV1/components/checkout/_payment-loading-skeleton.scss b/packages/core/src/scss/enhancedThemeV1/components/checkout/_payment-loading-skeleton.scss index 4ca1846cc0..bcf4e25249 100644 --- a/packages/core/src/scss/enhancedThemeV1/components/checkout/_payment-loading-skeleton.scss +++ b/packages/core/src/scss/enhancedThemeV1/components/checkout/_payment-loading-skeleton.scss @@ -15,46 +15,6 @@ div { animation: payment-method-skeleton-pulse 4000ms infinite cubic-bezier(0.13, 0.615, 0.315, 0.915); background: color("greys", "lighter"); - border-radius: $global-radius; - display: block; - } - - .payment-method-skeleton--row { - animation: none; - background: none; - border-radius: 0; - display: flex; - gap: spacing("single"); - - &:not(:last-child) { - margin-bottom: spacing("single"); - } - } - - .payment-method-skeleton--field-group { - animation: none; - background: none; - border-radius: 0; - } - - .payment-method-skeleton--field-group-large { - flex: 1; - } - - .payment-method-skeleton--field-group-small { - flex: 0 0 remCalc(105px); - } - - .payment-method-skeleton--label { - height: remCalc(10px); - margin-bottom: spacing("half"); - width: 40%; - } - - .payment-method-skeleton--field-large, - .payment-method-skeleton--field-small { - height: $floating-label-input-height; - width: 100%; } } } diff --git a/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx b/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx index 2f8f64eb4d..09c730ce2a 100644 --- a/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx +++ b/packages/ui/src/form/LoadingSkeleton/PaymentMethodSkeleton.tsx @@ -1,31 +1,9 @@ import React, { type FunctionComponent } from 'react'; +import ChecklistSkeleton from './ChecklistSkeleton'; + export const PaymentMethodSkeleton: FunctionComponent = () => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
); From 59c2975377d1a4409c5749cae136670551849dbb Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Thu, 10 Sep 2026 15:36:29 +1000 Subject: [PATCH 7/7] fix(checkout): CHECKOUT-10399 Fix cursor comment --- .../app/payment/paymentMethod/PaymentMethodList.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx index b528996ba1..001a2dc42d 100644 --- a/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx +++ b/packages/core/src/app/payment/paymentMethod/PaymentMethodList.tsx @@ -101,6 +101,7 @@ const PaymentMethodList: FunctionComponent< disabledReason={method === chequeMethod ? chequeDisabledReason : undefined} isEmbedded={isEmbedded} isInitializingPayment={isInitializingPayment} + isSelected={value === values.paymentProviderRadio} isUsingMultiShipping={isUsingMultiShipping} key={value} method={method} @@ -132,6 +133,7 @@ interface PaymentMethodListItemProps { disabledReason?: PoDisabledReason; isEmbedded?: boolean; isInitializingPayment?: boolean; + isSelected: boolean; isUsingMultiShipping?: boolean; method: PaymentMethod; value: string; @@ -142,12 +144,14 @@ const PaymentMethodListItem: FunctionComponent = ({ disabledReason, isEmbedded, isInitializingPayment, + isSelected, isUsingMultiShipping, method, onUnhandledError, value, }) => { const { enhancedThemeV1 } = useThemeContext(); + const isCustomChecklistItem = Boolean(method.initializationData?.isCustomChecklistItem); const renderPaymentMethod = useMemo(() => { const paymentMethod = ( @@ -159,10 +163,11 @@ const PaymentMethodListItem: FunctionComponent = ({ /> ); - return enhancedThemeV1 ? ( + // Custom checklist items manage their own loading UI + return enhancedThemeV1 && !isCustomChecklistItem ? ( } > {paymentMethod} @@ -172,8 +177,10 @@ const PaymentMethodListItem: FunctionComponent = ({ ); }, [ enhancedThemeV1, + isCustomChecklistItem, isEmbedded, isInitializingPayment, + isSelected, isUsingMultiShipping, method, onUnhandledError, @@ -191,7 +198,7 @@ const PaymentMethodListItem: FunctionComponent = ({ [disabledReason, method], ); - if (method.initializationData?.isCustomChecklistItem) { + if (isCustomChecklistItem) { return ; }