-
Notifications
You must be signed in to change notification settings - Fork 69
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add payment method logos to blocks card label
- Loading branch information
Showing
6 changed files
with
481 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { PaymentMethodsLogos } from './payment-methods-logos'; |
139 changes: 139 additions & 0 deletions
139
client/checkout/blocks/payment-methods-logos/logo-popover.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import React, { | ||
useEffect, | ||
useLayoutEffect, | ||
useRef, | ||
useState, | ||
useCallback, | ||
} from 'react'; | ||
|
||
interface LogoPopoverProps { | ||
id: string; | ||
className?: string; | ||
children: React.ReactNode; | ||
anchor: HTMLElement | null; | ||
open: boolean; | ||
onClose?: () => void; | ||
dataTestId?: string; | ||
} | ||
|
||
export const LogoPopover: React.FC< LogoPopoverProps > = ( { | ||
id, | ||
className, | ||
children, | ||
anchor, | ||
open, | ||
onClose, | ||
dataTestId, | ||
} ) => { | ||
const popoverRef = useRef< HTMLDivElement >( null ); | ||
const [ isPositioned, setIsPositioned ] = useState( false ); | ||
|
||
const updatePosition = useCallback( () => { | ||
const popover = popoverRef.current; | ||
if ( ! popover || ! anchor ) { | ||
return; | ||
} | ||
|
||
// Get the most up-to-date anchor rect | ||
const anchorRect = anchor.getBoundingClientRect(); | ||
|
||
// Temporarily make the popover visible to get correct dimensions | ||
popover.style.visibility = 'hidden'; | ||
popover.style.display = 'block'; | ||
const popoverRect = popover.getBoundingClientRect(); | ||
popover.style.display = ''; | ||
popover.style.visibility = ''; | ||
|
||
const offset = 7; | ||
const left = anchorRect.left; | ||
// Position the popover above the anchor | ||
const top = anchorRect.top - popoverRect.height - offset; | ||
|
||
popover.style.position = 'fixed'; | ||
popover.style.width = `${ anchorRect.width }px`; | ||
popover.style.left = `${ left }px`; | ||
popover.style.top = `${ top }px`; | ||
|
||
// Adjust position if popover goes off-screen | ||
if ( top < 0 ) { | ||
// If there's not enough space above, position it below the anchor | ||
popover.style.top = `${ anchorRect.bottom + offset }px`; | ||
} | ||
|
||
setIsPositioned( true ); | ||
}, [ anchor ] ); | ||
|
||
useLayoutEffect( () => { | ||
if ( open && anchor ) { | ||
// Use requestAnimationFrame to ensure the DOM has updated before positioning | ||
requestAnimationFrame( updatePosition ); | ||
} | ||
}, [ open, anchor, updatePosition ] ); | ||
|
||
useEffect( () => { | ||
if ( open && anchor ) { | ||
const observer = new MutationObserver( updatePosition ); | ||
observer.observe( anchor, { | ||
attributes: true, | ||
childList: true, | ||
subtree: true, | ||
} ); | ||
|
||
window.addEventListener( 'resize', updatePosition ); | ||
window.addEventListener( 'scroll', updatePosition ); | ||
|
||
const handleOutsideClick = ( event: MouseEvent ) => { | ||
if ( | ||
popoverRef.current && | ||
! popoverRef.current.contains( event.target as Node ) && | ||
! anchor.contains( event.target as Node ) | ||
) { | ||
onClose?.(); | ||
} | ||
}; | ||
|
||
const handleEscapeKey = ( event: KeyboardEvent ) => { | ||
if ( event.key === 'Escape' ) { | ||
onClose?.(); | ||
} | ||
}; | ||
|
||
document.addEventListener( 'mousedown', handleOutsideClick ); | ||
document.addEventListener( 'keydown', handleEscapeKey ); | ||
|
||
return () => { | ||
observer.disconnect(); | ||
window.removeEventListener( 'resize', updatePosition ); | ||
window.removeEventListener( 'scroll', updatePosition ); | ||
document.removeEventListener( 'mousedown', handleOutsideClick ); | ||
document.removeEventListener( 'keydown', handleEscapeKey ); | ||
}; | ||
} | ||
}, [ open, anchor, updatePosition, onClose ] ); | ||
|
||
if ( ! open ) { | ||
return null; | ||
} | ||
|
||
return ( | ||
<div | ||
id={ id } | ||
ref={ popoverRef } | ||
className={ `logo-popover ${ className || '' }` } | ||
style={ { | ||
position: 'fixed', | ||
zIndex: 1000, | ||
opacity: isPositioned ? 1 : 0, | ||
transition: 'opacity 0.2s', | ||
} } | ||
role="dialog" | ||
aria-label="Supported Credit Card Brands" | ||
data-testid={ dataTestId } | ||
> | ||
{ children } | ||
</div> | ||
); | ||
}; |
143 changes: 143 additions & 0 deletions
143
client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import React, { useState, useEffect, useCallback, useRef } from 'react'; | ||
/** | ||
* Internal dependencies | ||
*/ | ||
import { LogoPopover } from './logo-popover'; | ||
import './style.scss'; | ||
|
||
interface BreakpointConfig { | ||
breakpoint: number; | ||
maxElements: number; | ||
} | ||
|
||
interface PaymentMethodsLogosProps { | ||
maxElements: number; | ||
paymentMethods: { name: string; component: string }[]; | ||
breakpointConfigs?: BreakpointConfig[]; | ||
} | ||
|
||
const breakpointConfigsDefault = [ | ||
{ breakpoint: 480, maxElements: 5 }, | ||
{ breakpoint: 768, maxElements: 7 }, | ||
]; | ||
const paymentMethodsDefault: never[] = []; | ||
export const PaymentMethodsLogos: React.FC< PaymentMethodsLogosProps > = ( { | ||
maxElements = 10, | ||
paymentMethods = paymentMethodsDefault, | ||
breakpointConfigs = breakpointConfigsDefault, | ||
} ) => { | ||
const [ maxShownElements, setMaxShownElements ] = useState( maxElements ); | ||
const [ | ||
popoverAnchor, | ||
setPopoverAnchor, | ||
] = useState< HTMLDivElement | null >( null ); | ||
const [ popoverOpen, setPopoverOpen ] = useState( false ); | ||
const [ shouldHavePopover, setShouldHavePopover ] = useState( false ); | ||
|
||
const togglePopover = () => setPopoverOpen( ! popoverOpen ); | ||
|
||
const anchorRef = useCallback( ( node: HTMLDivElement | null ) => { | ||
if ( node !== null ) { | ||
setPopoverAnchor( node ); | ||
} | ||
}, [] ); | ||
|
||
const buttonRef = useRef< HTMLDivElement | null >( null ); | ||
|
||
const handlePopoverClose = useCallback( () => { | ||
setPopoverOpen( false ); | ||
buttonRef.current?.focus(); | ||
}, [] ); | ||
|
||
useEffect( () => { | ||
const updateMaxElements = () => { | ||
const sortedConfigs = [ ...breakpointConfigs ].sort( | ||
( a, b ) => a.breakpoint - b.breakpoint | ||
); | ||
const config = sortedConfigs.find( | ||
( cfg ) => window.innerWidth <= cfg.breakpoint | ||
); | ||
|
||
setMaxShownElements( config ? config.maxElements : maxElements ); | ||
}; | ||
|
||
updateMaxElements(); | ||
window.addEventListener( 'resize', updateMaxElements ); | ||
|
||
return () => window.removeEventListener( 'resize', updateMaxElements ); | ||
}, [ breakpointConfigs, maxElements ] ); | ||
|
||
useEffect( () => { | ||
if ( popoverAnchor ) { | ||
buttonRef.current = popoverAnchor; | ||
} | ||
}, [ popoverAnchor ] ); | ||
|
||
useEffect( () => { | ||
setShouldHavePopover( paymentMethods.length > maxShownElements ); | ||
}, [ maxShownElements, paymentMethods.length ] ); | ||
|
||
return ( | ||
<> | ||
<div className="payment-methods--logos"> | ||
<div | ||
ref={ anchorRef } | ||
{ ...( shouldHavePopover && { | ||
onClick: togglePopover, | ||
onKeyDown: ( e ) => { | ||
if ( e.key === 'Enter' || e.key === ' ' ) { | ||
e.preventDefault(); | ||
togglePopover(); | ||
} | ||
}, | ||
role: 'button', | ||
tabIndex: 0, | ||
'aria-expanded': popoverOpen, | ||
'aria-controls': 'payment-methods-popover', | ||
} ) } | ||
data-testid="payment-methods-logos" | ||
> | ||
{ paymentMethods | ||
.slice( 0, maxShownElements ) | ||
.map( ( pm ) => ( | ||
<img | ||
key={ pm.name } | ||
alt={ pm.name } | ||
src={ pm.component } | ||
width={ 38 } | ||
height={ 24 } | ||
/> | ||
) ) } | ||
{ shouldHavePopover && ( | ||
<div className="payment-methods--logos-count"> | ||
+ { paymentMethods.length - maxShownElements } | ||
</div> | ||
) } | ||
</div> | ||
</div> | ||
{ shouldHavePopover && popoverOpen && ( | ||
<LogoPopover | ||
id="payment-methods-popover" | ||
className="payment-methods--logos-popover" | ||
anchor={ popoverAnchor } | ||
open={ popoverOpen } | ||
onClose={ handlePopoverClose } | ||
dataTestId="payment-methods-popover" | ||
> | ||
{ paymentMethods.slice( maxShownElements ).map( ( pm ) => ( | ||
<img | ||
key={ pm.name } | ||
alt={ pm.name } | ||
src={ pm.component } | ||
width={ 38 } | ||
height={ 24 } | ||
/> | ||
) ) } | ||
</LogoPopover> | ||
) } | ||
</> | ||
); | ||
}; |
Oops, something went wrong.