diff --git a/packages/lib/src/components/internal/Toggle/Toggle.scss b/packages/lib/src/components/internal/Toggle/Toggle.scss new file mode 100644 index 0000000000..f015d14626 --- /dev/null +++ b/packages/lib/src/components/internal/Toggle/Toggle.scss @@ -0,0 +1,158 @@ +@use 'styles/mixins'; +@import 'styles/variable-generator'; + +.adyen-checkout-toggle { + $component-root: &; + $label-padding: token(toggle-label-padding); + + color: inherit; + cursor: pointer; + display: flex; + width: auto; + + @include mixins.box-sizing-setter(true); + + &--disabled { + cursor: not-allowed; + display: flex; + } + + &--readonly { + pointer-events: none; + } + + &--label-first { + align-items: flex-start; + flex-direction: row-reverse; + justify-content: flex-end; + } + + &__input { + cursor: inherit; + opacity: 0; + position: absolute; + } + + &__track { + align-items: center; + background-color: token(toggle-track-background-color); + border: token(toggle-track-border); + border-radius: token(toggle-track-border-radius); + display: flex; + height: token(toggle-track-height); + min-width: token(toggle-track-width); + padding: token(toggle-track-padding); + position: relative; + + #{$component-root}__input:focus-visible + & { + @include mixins.b-focus-ring; + } + + #{$component-root}__input:hover:enabled + & { + background-color: token(toggle-track-hover-background-color); + border-color: token(toggle-track-hover-border-color); + } + + #{$component-root}__input:active:enabled + & { + background-color: token(toggle-track-active-background-color); + border-color: token(toggle-track-active-border-color); + } + + #{$component-root}__input:disabled + & { + background-color: token(toggle-track-disabled-background-color); + border-color: token(toggle-track-disabled-border-color); + cursor: not-allowed; + + path { + fill: #8d95a3 + } + } + + #{$component-root}--readonly #{$component-root}__input + & { + background-color: token(toggle-track-readonly-background-color); + border-color: token(toggle-track-readonly-border-color); + } + + #{$component-root}__input:checked + & { + background-color: token(toggle-track-toggled-background-color); + border: token(toggle-track-toggled-border); + padding: token(toggle-track-toggled-padding); + } + + #{$component-root}__input:checked:hover:enabled + & { + background-color: token(toggle-track-toggled-hover-background-color); + } + + #{$component-root}__input:checked:active:enabled + & { + background-color: token(toggle-track-toggled-active-background-color); + } + + #{$component-root}__input:checked:disabled + & { + background-color: token(toggle-track-toggled-disabled-background-color); + } + + #{$component-root}--readonly #{$component-root}__input:checked + & { + background-color: token(toggle-track-toggled-readonly-background-color); + } + } + + &__handle { + align-content: center; + background-color: token(toggle-handle-background-color); + border-radius: token(toggle-handle-border-radius); + color: token(toggle-handle-toggled-color); + display: inline-flex; + height: token(toggle-handle-height); + justify-content: center; + transition: token(toggle-handle-transition); + width: token(toggle-handle-width); + + #{$component-root}__input:disabled + * & { + background-color: token(toggle-handle-disabled-background-color); + cursor: not-allowed; + } + + #{$component-root}__input:checked + * & { + background-color: token(toggle-handle-toggled-background-color); + height: token(toggle-handle-toggled-height); + transform: translateX(100%); + width: token(toggle-handle-toggled-width); + } + + #{$component-root}__input:checked:disabled + * & { + background-color: token(toggle-handle-toggled-disabled-background-color); + color: token(toggle-handle-toggled-disabled-color); + cursor: not-allowed; + } + + #{$component-root}--readonly #{$component-root}__input:checked + * & { + background-color: token(toggle-handle-toggled-readonly-background-color); + } + } + + &__label-container { + display: flex; + flex-direction: column; + padding-left: $label-padding; + + @include mixins.adyen-checkout-text-body; + + #{$component-root}--label-first > & { + padding-left: 0; + padding-right: $label-padding; + } + } + + &__label { + vertical-align: baseline; + + @include mixins.adyen-checkout-text-body; + } + + &__description { + color: token(toggle-description-color); + padding-top: token(toggle-description-padding); + + @include mixins.adyen-checkout-text-body; + } +} diff --git a/packages/lib/src/components/internal/Toggle/Toggle.test.tsx b/packages/lib/src/components/internal/Toggle/Toggle.test.tsx new file mode 100644 index 0000000000..5529e5e720 --- /dev/null +++ b/packages/lib/src/components/internal/Toggle/Toggle.test.tsx @@ -0,0 +1,32 @@ +import { h } from 'preact'; +import { render, screen } from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import Toggle from './Toggle'; + +test('should emit correct values', async () => { + const user = userEvent.setup(); + const onChangeMock = jest.fn(); + + render(); + + await user.click(screen.getByRole('switch')); + expect(onChangeMock.mock.calls[0][0]).toBe(true); + + await user.click(screen.getByRole('switch')); + expect(onChangeMock.mock.calls[1][0]).toBe(false); +}); + +test('should render as readonly', () => { + render(); + expect(screen.getByRole('switch').getAttribute('aria-readonly')).toBe('true'); +}); + +test('should render as disabled', () => { + render(); + expect(screen.getByRole('switch')).toBeDisabled(); +}); + +test('should render description', () => { + render(); + expect(screen.getByText('Save all details')).toBeTruthy(); +}); diff --git a/packages/lib/src/components/internal/Toggle/Toggle.tsx b/packages/lib/src/components/internal/Toggle/Toggle.tsx new file mode 100644 index 0000000000..4e2594345d --- /dev/null +++ b/packages/lib/src/components/internal/Toggle/Toggle.tsx @@ -0,0 +1,78 @@ +import { h } from 'preact'; +import { useCallback, useMemo } from 'preact/hooks'; +import cx from 'classnames'; +import uuid from '../../../utils/uuid'; +import './Toggle.scss'; + +interface ToggleProps { + label?: string; + labelPosition?: 'before' | 'after'; + ariaLabel?: string; + description?: string; + checked: boolean; + disabled?: boolean; + readonly?: boolean; + onChange?(checked: boolean): void; +} + +const Toggle = ({ label, labelPosition = 'after', ariaLabel, description, checked, disabled = false, readonly = false, onChange }: ToggleProps) => { + const descriptionId = useMemo(() => (description ? `toggle-description-${uuid()}` : null), [description]); + const computedAriaLabel = useMemo(() => ariaLabel ?? label, [ariaLabel, label]); + + const conditionalClasses = cx({ + 'adyen-checkout-toggle--label-first': labelPosition === 'before', + 'adyen-checkout-toggle--disabled': disabled, + 'adyen-checkout-toggle--readonly': readonly + }); + + const onInputChange = useCallback( + (event: Event) => { + onChange((event.target as HTMLInputElement).checked); + }, + [onChange] + ); + + return ( + + ); +}; + +export default Toggle; diff --git a/packages/lib/src/components/internal/Toggle/index.ts b/packages/lib/src/components/internal/Toggle/index.ts new file mode 100644 index 0000000000..c2ec545530 --- /dev/null +++ b/packages/lib/src/components/internal/Toggle/index.ts @@ -0,0 +1 @@ +export { default } from './Toggle'; diff --git a/packages/lib/src/styles/mixins.scss b/packages/lib/src/styles/mixins.scss index ad418d0b20..926f55dd38 100644 --- a/packages/lib/src/styles/mixins.scss +++ b/packages/lib/src/styles/mixins.scss @@ -105,6 +105,12 @@ $adyen-checkout-media-query-l-min: 1024px; line-height: token(text-title-line-height); } +@mixin adyen-checkout-text-body { + font-size: token(text-body-font-size); + font-weight: token(text-body-font-weight); + line-height: token(text-body-line-height); +} + @mixin adyen-checkout-text-caption { font-size: token(text-caption-font-size); font-weight: token(text-caption-font-weight); @@ -133,4 +139,4 @@ $adyen-checkout-media-query-l-min: 1024px; margin: -1px; padding: 0; position: absolute; -} \ No newline at end of file +} diff --git a/packages/lib/src/styles/variable-generator.scss b/packages/lib/src/styles/variable-generator.scss index 08e3ad21da..ba6322943e 100644 --- a/packages/lib/src/styles/variable-generator.scss +++ b/packages/lib/src/styles/variable-generator.scss @@ -1,5 +1,6 @@ @import '~@adyen/bento-design-tokens/dist/scss-map/bento/aliases'; @import '~@adyen/bento-design-tokens/dist/scss-map/bento/definitions'; +@import '~@adyen/bento-design-tokens/dist/scss-map/bento/components'; @function adyen-sdk-generate-css-variables($maps...) { $adyen-output-map: (); @@ -18,9 +19,9 @@ $adyen-tokens-map: (); @if $generate-css-var { - $adyen-tokens-map: adyen-sdk-generate-css-variables($color, $text, $focus-ring, $border, $spacer, $shadow); + $adyen-tokens-map: adyen-sdk-generate-css-variables($color, $text, $focus-ring, $border, $spacer, $shadow, $toggle); } @else { - $adyen-tokens-map: map-merge($color, $text, $focus-ring, $border, $spacer, $shadow) + $adyen-tokens-map: map-merge($color, $text, $focus-ring, $border, $spacer, $shadow, $toggle) } @return map-get($adyen-tokens-map, '#{$token}'); diff --git a/packages/lib/storybook/stories/internals/Toggle.stories.tsx b/packages/lib/storybook/stories/internals/Toggle.stories.tsx new file mode 100644 index 0000000000..a8394429ae --- /dev/null +++ b/packages/lib/storybook/stories/internals/Toggle.stories.tsx @@ -0,0 +1,65 @@ +import { Meta, StoryObj } from '@storybook/preact'; +import Toggle from '../../../src/components/internal/Toggle'; +import { useState } from 'preact/hooks'; + +const meta: Meta = { + title: 'Internals/Toggle', + component: Toggle +}; + +export const Default: StoryObj = { + render: () => { + const [checked, setChecked] = useState(false); + return ; + }, + parameters: { + controls: { exclude: ['useSessions', 'countryCode', 'shopperLocale', 'amount', 'showPayButton'] } + } +}; + +export const Disabled: StoryObj = { + render: () => { + return ; + }, + parameters: { + controls: { exclude: ['useSessions', 'countryCode', 'shopperLocale', 'amount', 'showPayButton'] } + } +}; + +export const Readonly: StoryObj = { + render: () => { + return ; + }, + parameters: { + controls: { exclude: ['useSessions', 'countryCode', 'shopperLocale', 'amount', 'showPayButton'] } + } +}; + +export const ToggleOnly: StoryObj = { + render: () => { + const [checked, setChecked] = useState(true); + return ; + }, + parameters: { + controls: { exclude: ['useSessions', 'countryCode', 'shopperLocale', 'amount', 'showPayButton'] } + } +}; + +export const WithDescription: StoryObj = { + render: () => { + const [checked, setChecked] = useState(true); + return ( + + ); + }, + parameters: { + controls: { exclude: ['useSessions', 'countryCode', 'shopperLocale', 'amount', 'showPayButton'] } + } +}; + +export default meta;