Skip to content
5 changes: 5 additions & 0 deletions .changeset/famous-cows-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adyen/adyen-web': patch
---

Fix accessibility issue with opening klarna widget with enter button
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface FixedAmountsProps {
values: Array<number>;
status: Status;
onAmountSelected: ({ target }) => void;
onDonateButtonClicked: (amount: number) => void;
onDonateButtonClicked: () => void;
}
export default function FixedAmounts(props: FixedAmountsProps) {
const { currency, values, selectedAmount, status, onAmountSelected, onDonateButtonClicked } = props;
Expand Down
3 changes: 2 additions & 1 deletion packages/lib/src/components/Klarna/KlarnaPayments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { KlarnaContainer } from './components/KlarnaContainer/KlarnaContainer';
import { TxVariants } from '../tx-variants';
import type { KlarnaAction, KlarnaAdditionalDetailsData, KlarnaComponentRef, KlarnaConfiguration } from './types';
import type { ICore } from '../../core/types';
import { PayButtonFunctionProps } from '../internal/UIElement/types';

class KlarnaPayments extends UIElement<KlarnaConfiguration> {
public static type = TxVariants.klarna;
Expand Down Expand Up @@ -38,7 +39,7 @@ class KlarnaPayments extends UIElement<KlarnaConfiguration> {
};
}

public payButton = props => {
public payButton = (props: PayButtonFunctionProps) => {
return <PayButton amount={this.props.amount} onClick={this.submit} {...props} />;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { h } from 'preact';
import { fireEvent, render, screen } from '@testing-library/preact';
import { render, screen } from '@testing-library/preact';
import userEvent from '@testing-library/user-event';
import { KlarnaWidget } from './KlarnaWidget';
import Script from '../../../../utils/Script';
import { KLARNA_WIDGET_URL } from '../../constants';
import { KlarnaWidgetAuthorizeResponse, type KlarnaWidgetProps } from '../../types';
import { CoreProvider } from '../../../../core/Context/CoreProvider';
import { mock } from 'jest-mock-extended';
import { AnalyticsModule } from '../../../../types/global-types';
import { PayButtonFunctionProps } from '../../../internal/UIElement/types';

jest.mock('../../../../utils/Script', () => {
return jest.fn().mockImplementation(() => {
Expand Down Expand Up @@ -42,7 +44,7 @@ describe('KlarnaWidget', () => {
const paymentData = 'test';
const paymentMethodType = 'klarna';
const sdkData = { client_token: '123', payment_method_category: 'paynow' };
const payButton = props => (
const payButton = (props: PayButtonFunctionProps) => (
<button data-testid="pay-with-klarna" {...props}>
Pay with Klarna
</button>
Expand Down Expand Up @@ -115,11 +117,12 @@ describe('KlarnaWidget', () => {

describe('Pay with Klarna widget', () => {
test('should call the onComplete if the payment is authorized', async () => {
const user = userEvent.setup();
const authRes = { approved: true, show_form: true, authorization_token: 'abc' };
klarnaObj.Payments.load = getKlarnaActionImp({ show_form: true });
klarnaObj.Payments.authorize = getKlarnaActionImp(authRes);
customRender(props);
fireEvent.click(await screen.findByTestId(/pay-with-klarna/i));
await user.click(await screen.findByTestId(/pay-with-klarna/i));
expect(onComplete).toHaveBeenCalledWith({
data: {
paymentData,
Expand All @@ -129,20 +132,65 @@ describe('KlarnaWidget', () => {
}
});
});

test('should call the onComplete if the payment is authorized after pay button is triggered with {Enter} key', async () => {
const user = userEvent.setup();
const authRes = { approved: true, show_form: true, authorization_token: 'abc' };
klarnaObj.Payments.load = getKlarnaActionImp({ show_form: true });
klarnaObj.Payments.authorize = getKlarnaActionImp(authRes);
customRender(props);

const payButton = await screen.findByTestId(/pay-with-klarna/i);
payButton.focus();
await user.keyboard('{Enter}');

expect(onComplete).toHaveBeenCalledWith({
data: {
paymentData,
details: {
authorization_token: authRes.authorization_token
}
}
});
});

test('should call the onComplete if the payment is authorized after pay button is triggered with {Space} key', async () => {
const user = userEvent.setup();
const authRes = { approved: true, show_form: true, authorization_token: 'abc' };
klarnaObj.Payments.load = getKlarnaActionImp({ show_form: true });
klarnaObj.Payments.authorize = getKlarnaActionImp(authRes);
customRender(props);

const payButton = await screen.findByTestId(/pay-with-klarna/i);
payButton.focus();
await user.keyboard('{Space}');

expect(onComplete).toHaveBeenCalledWith({
data: {
paymentData,
details: {
authorization_token: authRes.authorization_token
}
}
});
});

test('should call the onError if the payment is not authorized temporarily', async () => {
const user = userEvent.setup();
klarnaObj.Payments.load = getKlarnaActionImp({ show_form: true });
const authRes = { approved: false, show_form: true };
klarnaObj.Payments.authorize = getKlarnaActionImp(authRes);
customRender(props);
fireEvent.click(await screen.findByTestId(/pay-with-klarna/i));
await user.click(await screen.findByTestId(/pay-with-klarna/i));
expect(onError).toHaveBeenCalledWith(authRes);
});

test('should call the onComplete if the payment is not authorized permanently', async () => {
const user = userEvent.setup();
klarnaObj.Payments.load = getKlarnaActionImp({ show_form: true });
klarnaObj.Payments.authorize = getKlarnaActionImp({ show_form: false });
customRender(props);
fireEvent.click(await screen.findByTestId(/pay-with-klarna/i));
await user.click(await screen.findByTestId(/pay-with-klarna/i));
expect(onComplete).toHaveBeenCalledWith({
data: {
paymentData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function KlarnaWidget({ sdkData, paymentMethodType, widgetInitializationT
container: klarnaWidgetRef.current,
payment_method_category: sdkData.payment_method_category
},
function (res) {
function (res: { show_form: boolean; error: unknown }) {
// If show_form: true is received together with an error, something fixable is wrong and the consumer
// needs to take action before moving forward
// If show_form: false, the payment method in the loaded widget will not be offered for this order
Expand Down Expand Up @@ -81,6 +81,19 @@ export function KlarnaWidget({ sdkData, paymentMethodType, widgetInitializationT
}
}, [sdkData.payment_method_category, props.onComplete, props.onError]);

/**
* TODO: Clean this up when we have a different solution for handling on click in the BaseElement class
* We need this specifically for handling ENTER keypresses from the keyboard
* because the UIElement class has an on keypress handler which can trigger a components submit function
* ENTER key press on this button should not trigger this behaviour since the Klarna script has already been loaded
*/
const handleKeyDown = (e: h.JSX.TargetedKeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.code === 'Enter') {
e.preventDefault();
authorizeKlarna();
}
};

/**
* Initializes Klarna SDK if it is already available and reinitialize
* it when the init time refreshes
Expand Down Expand Up @@ -113,7 +126,12 @@ export function KlarnaWidget({ sdkData, paymentMethodType, widgetInitializationT
return (
<div className="adyen-checkout__klarna-widget">
<div ref={klarnaWidgetRef} />
{payButton({ status, disabled: status === 'loading', onClick: authorizeKlarna })}
{payButton({
status,
disabled: status === 'loading',
onClick: authorizeKlarna,
onKeyDown: handleKeyDown
})}
</div>
);
}
Expand Down
9 changes: 5 additions & 4 deletions packages/lib/src/components/internal/Button/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { h } from 'preact';
import { mount } from 'enzyme';
import Button from './Button';
import { CoreProvider } from '../../../core/Context/CoreProvider';
import { ButtonProps } from './types';

const getWrapper = props => {
const getWrapper = (props: ButtonProps) => {
return mount(
<CoreProvider i18n={global.i18n} loadingContext="test" resources={global.resources}>
<Button {...props} />
Expand Down Expand Up @@ -58,17 +59,17 @@ describe('Button', () => {
});

test('Renders secondary button', () => {
const wrapper = getWrapper({ variant: 'secondary ' });
const wrapper = getWrapper({ variant: 'secondary' });
expect(wrapper.find('.adyen-checkout__button--secondary').length).toBe(1);
});

test('Renders action button', () => {
const wrapper = getWrapper({ variant: 'action ' });
const wrapper = getWrapper({ variant: 'action' });
expect(wrapper.find('.adyen-checkout__button--action').length).toBe(1);
});

test('Renders ghost button', () => {
const wrapper = getWrapper({ variant: 'ghost ' });
const wrapper = getWrapper({ variant: 'ghost' });
expect(wrapper.find('.adyen-checkout__button--ghost').length).toBe(1);
});
});
19 changes: 5 additions & 14 deletions packages/lib/src/components/internal/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,14 @@ class Button extends Component<ButtonProps, ButtonState> {
disabled: false,
label: '',
inline: false,
target: '_self',
onClick: () => {},
onMouseEnter: () => {},
onMouseLeave: () => {},
onFocus: () => {},
onBlur: () => {},
onKeyPress: () => {}
target: '_self'
};

public onClick = e => {
public onClick = (e: h.JSX.TargetedMouseEvent<HTMLButtonElement>) => {
e.preventDefault();

if (!this.props.disabled) {
this.props.onClick(e, { complete: this.complete });
this.props.onClick?.(e, { complete: this.complete });
}
};

Expand All @@ -36,10 +30,6 @@ class Button extends Component<ButtonProps, ButtonState> {
}, delay);
};

public onKeyDown = (event: KeyboardEvent) => {
this.props.onKeyDown?.(event);
};

render() {
const {
classNameModifiers = [],
Expand All @@ -59,6 +49,7 @@ class Button extends Component<ButtonProps, ButtonState> {
onMouseLeave,
onFocus,
onBlur,
onKeyDown,
onKeyPress
}: ButtonProps = this.props;
const { completed } = this.state;
Expand Down Expand Up @@ -124,11 +115,11 @@ class Button extends Component<ButtonProps, ButtonState> {
type="button"
disabled={disabled}
onClick={this.onClick}
onKeyDown={this.onKeyDown}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
onKeyPress={onKeyPress}
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/src/components/internal/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface ButtonProps {
href?: string;
target?: string;
rel?: string;
onClick?: (e, callbacks) => void;
onClick?: (e: h.JSX.TargetedMouseEvent<HTMLButtonElement>, callbacks?: { complete?: () => void }) => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyPress?: (event: KeyboardEvent) => void;
buttonRef?: Ref<HTMLButtonElement>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface PayButtonProps extends ButtonProps {
status?: string;
disabled?: boolean;
icon?: string;
onClick?(): void;
onClick?: (e: h.JSX.TargetedMouseEvent<HTMLButtonElement>) => void;
}

const PayButton = ({ amount, secondaryAmount, classNameModifiers = [], label, ...props }: PayButtonProps) => {
Expand Down
Loading