From 7c433617ce59c5e5c7b6e2f8a21e6330e92249de Mon Sep 17 00:00:00 2001 From: souyahia Date: Tue, 26 Dec 2023 10:57:40 +0100 Subject: [PATCH] Created SwitchButton component --- .../src/__mocks__/@monkvision/common.tsx | 2 + packages/private/test-utils/src/index.ts | 1 + packages/private/test-utils/src/styles.ts | 7 + packages/public/common-ui-web/README.md | 32 ++++ .../SwitchButton/SwitchButton.styles.ts | 102 +++++++++++ .../components/SwitchButton/SwitchButton.tsx | 83 +++++++++ .../src/components/SwitchButton/hooks.ts | 169 ++++++++++++++++++ .../src/components/SwitchButton/index.ts | 2 + .../common-ui-web/src/components/index.ts | 1 + .../test/components/Button/Button.test.tsx | 19 +- .../test/components/SwitchButton.test.tsx | 101 +++++++++++ .../public/common/src/utils/color.utils.ts | 11 ++ .../common/test/utils/color.utils.test.ts | 8 + 13 files changed, 528 insertions(+), 10 deletions(-) create mode 100644 packages/private/test-utils/src/styles.ts create mode 100644 packages/public/common-ui-web/src/components/SwitchButton/SwitchButton.styles.ts create mode 100644 packages/public/common-ui-web/src/components/SwitchButton/SwitchButton.tsx create mode 100644 packages/public/common-ui-web/src/components/SwitchButton/hooks.ts create mode 100644 packages/public/common-ui-web/src/components/SwitchButton/index.ts create mode 100644 packages/public/common-ui-web/test/components/SwitchButton.test.tsx diff --git a/packages/private/test-utils/src/__mocks__/@monkvision/common.tsx b/packages/private/test-utils/src/__mocks__/@monkvision/common.tsx index 59e373495..083a2c58c 100644 --- a/packages/private/test-utils/src/__mocks__/@monkvision/common.tsx +++ b/packages/private/test-utils/src/__mocks__/@monkvision/common.tsx @@ -9,6 +9,7 @@ const { toCamelCase, getRGBAFromString, getHexFromRGBA, + changeAlpha, shadeColor, InteractiveVariation, getInteractiveVariants, @@ -30,6 +31,7 @@ export = { toCamelCase, getRGBAFromString, getHexFromRGBA, + changeAlpha, shadeColor, InteractiveVariation, getInteractiveVariants, diff --git a/packages/private/test-utils/src/index.ts b/packages/private/test-utils/src/index.ts index a068abfdd..55d594f24 100644 --- a/packages/private/test-utils/src/index.ts +++ b/packages/private/test-utils/src/index.ts @@ -1,2 +1,3 @@ export * from './expects'; export * from './dom'; +export * from './styles'; diff --git a/packages/private/test-utils/src/styles.ts b/packages/private/test-utils/src/styles.ts new file mode 100644 index 000000000..d446bfc0b --- /dev/null +++ b/packages/private/test-utils/src/styles.ts @@ -0,0 +1,7 @@ +export function getNumberFromCSSProperty(prop: string | undefined | null): number { + expect(typeof prop).toBe('string'); + const reg = /^(\d+)\D*$/; + expect(prop).toMatch(reg); + const result = (prop as string).match(reg); + return Number((result as string[])[1]); +} diff --git a/packages/public/common-ui-web/README.md b/packages/public/common-ui-web/README.md index 5223ec832..d47da5a76 100644 --- a/packages/public/common-ui-web/README.md +++ b/packages/public/common-ui-web/README.md @@ -157,3 +157,35 @@ function MyComponent() { | sight | Sight | The sight to display the SVG overlay of. | ✔️ | | | getAttributes | (element: Element, groupIds: string[]) => SVGProps | null | A customization function that lets you specify custom HTML attributes to give to the tags in the SVG file based on the HTML element itself and the IDs of the groups it is part of. | | | | getInnerText | (element: Element, groupIds: string[]) => string | null | A customization function that lets you specify the innner text of the tags in the SVG file based on the HTML element itself and the IDs of the groups it is part of. | | | + +## SwitchButton +### Description +Switch button component that can be used to turn ON or OFF a feature. + +### Example +```tsx +import { SwitchButton } from '@monkvision/common-ui-web'; + +export function MyComponent() { + const [checked, setChecked] = useState(false); + return ( +
+ setChecked(value)} /> +
+ ); +} +``` + +### Props +| Prop | Type | Description | Required | Default Value | +|-------------------------|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-----------------| +| size | `'normal' | 'small'` | The size of the button. Normal buttons are bigger and have their icon and labels inside the button. Small buttons are smaller, accept no label and have their icon inside the knob. | | 'normal' | +| checked | boolean | Boolean used to control the SwitchButton. Set to `true` to make the Button switched on and `false` for off. | | false | +| onSwitch | `(value: boolean) => void` | Callback called when the SwitchButton is switched. The value passed as the first parameter is the result `checked` value. | | | +| disabled | boolean | Boolean indicating if the button is disabled or not. | | false | +| checkedPrimaryColor | ColorProp | Primary color (background and knob overlay color) of the button when it is checked. | | 'primary' | +| checkedSecondaryColor | ColorProp | Secondary color (knob, labels and icons color) of the button when it is checked. | | 'text-white' | +| uncheckedPrimaryColor | ColorProp | Primary color (background and knob overlay color) of the button when it is unchecked. | | 'text-tertiary' | +| uncheckedSecondaryColor | ColorProp | Secondary color (knob, labels and icons color) of the button when it is unchecked. | | 'text-white' | +| checkedLabel | ColorProp | Custom label that can be displayed instead of the check icon when the button is checked. This prop is ignored for small buttons. | | | +| uncheckedLabel | ColorProp | Custom label that can be displayed when the button is unchecked. This prop is ignored for small buttons. | | | diff --git a/packages/public/common-ui-web/src/components/SwitchButton/SwitchButton.styles.ts b/packages/public/common-ui-web/src/components/SwitchButton/SwitchButton.styles.ts new file mode 100644 index 000000000..23eb8a7eb --- /dev/null +++ b/packages/public/common-ui-web/src/components/SwitchButton/SwitchButton.styles.ts @@ -0,0 +1,102 @@ +import { Styles } from '@monkvision/types'; + +export const slideTransitionFrames = '0.3s ease-out'; + +export const sizes = { + normal: { + width: 65, + height: 30, + knobPadding: 2, + labelPadding: 10, + knobOverlayExpansion: 3, + iconSize: 18, + }, + small: { + width: 36, + height: 22, + knobPadding: 2, + knobOverlayExpansion: 2, + iconSize: 10, + }, +}; + +export const styles: Styles = { + button: { + width: sizes.normal.width, + height: sizes.normal.height, + padding: `0 ${sizes.normal.labelPadding}px`, + overflow: 'visible', + borderRadius: 9999999, + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + border: 'none', + cursor: 'pointer', + transition: `background-color ${slideTransitionFrames}`, + }, + buttonDisabled: { + opacity: 0.37, + cursor: 'default', + }, + buttonSmall: { + padding: 0, + width: sizes.small.width, + height: sizes.small.height, + }, + knobOverlay: { + width: sizes.normal.height + 2 * sizes.normal.knobOverlayExpansion, + height: sizes.normal.height + 2 * sizes.normal.knobOverlayExpansion, + borderRadius: '50%', + position: 'absolute', + top: -sizes.normal.knobOverlayExpansion, + left: -sizes.normal.knobOverlayExpansion, + transition: `left ${slideTransitionFrames}`, + }, + knobOverlaySmall: { + width: sizes.small.height + 2 * sizes.small.knobOverlayExpansion, + height: sizes.small.height + 2 * sizes.small.knobOverlayExpansion, + top: -sizes.small.knobOverlayExpansion, + left: -sizes.small.knobOverlayExpansion, + }, + knobOverlayChecked: { + left: sizes.normal.width - sizes.normal.height - sizes.normal.knobOverlayExpansion, + }, + knobOverlaySmallChecked: { + left: sizes.small.width - sizes.small.height - sizes.small.knobOverlayExpansion, + }, + knob: { + width: sizes.normal.height - 2 * sizes.normal.knobPadding, + height: sizes.normal.height - 2 * sizes.normal.knobPadding, + borderRadius: '50%', + position: 'absolute', + top: sizes.normal.knobPadding, + left: sizes.normal.knobPadding, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: `left ${slideTransitionFrames}`, + }, + knobSmall: { + width: sizes.small.height - 2 * sizes.small.knobPadding, + height: sizes.small.height - 2 * sizes.small.knobPadding, + top: sizes.small.knobPadding, + left: sizes.small.knobPadding, + }, + knobChecked: { + left: sizes.normal.width - sizes.normal.height + sizes.normal.knobPadding, + }, + knobSmallChecked: { + left: sizes.small.width - sizes.small.height + sizes.small.knobPadding, + }, + label: { + fontSize: 12, + fontWeight: 400, + lineHeight: '18px', + letterSpacing: '0.4px', + transition: `opacity ${slideTransitionFrames}`, + }, + icon: { + transition: `opacity ${slideTransitionFrames}`, + }, +}; diff --git a/packages/public/common-ui-web/src/components/SwitchButton/SwitchButton.tsx b/packages/public/common-ui-web/src/components/SwitchButton/SwitchButton.tsx new file mode 100644 index 000000000..d361ee7c3 --- /dev/null +++ b/packages/public/common-ui-web/src/components/SwitchButton/SwitchButton.tsx @@ -0,0 +1,83 @@ +import { useInteractiveStatus } from '@monkvision/common'; +import { SwitchButtonProps, useSwitchButtonStyles } from './hooks'; +import { Icon } from '../../icons'; + +/** + * Switch button component that can be used to turn ON or OFF a feature. + * + * @see https://uxplanet.org/checkbox-vs-toggle-switch-7fc6e83f10b8 + * @example + * export function MyComponent() { + * const [checked, setChecked] = useState(false); + * return ( + *
+ * setChecked(value)} /> + *
+ * ); + * } + */ +export function SwitchButton({ + size = 'normal', + checked = false, + onSwitch, + disabled = false, + checkedPrimaryColor = 'primary', + checkedSecondaryColor = 'text-white', + uncheckedPrimaryColor = 'text-tertiary', + uncheckedSecondaryColor = 'text-white', + checkedLabel, + uncheckedLabel, +}: SwitchButtonProps) { + const { status, eventHandlers } = useInteractiveStatus({ disabled }); + const switchButtonStyles = useSwitchButtonStyles({ + size, + checked, + checkedPrimaryColor, + checkedSecondaryColor, + uncheckedPrimaryColor, + uncheckedSecondaryColor, + status, + }); + + const handleSwitch = () => { + if (onSwitch) { + onSwitch(!checked); + } + }; + + return ( + + ); +} diff --git a/packages/public/common-ui-web/src/components/SwitchButton/hooks.ts b/packages/public/common-ui-web/src/components/SwitchButton/hooks.ts new file mode 100644 index 000000000..f1eae1cf6 --- /dev/null +++ b/packages/public/common-ui-web/src/components/SwitchButton/hooks.ts @@ -0,0 +1,169 @@ +import { CSSProperties, useMemo } from 'react'; +import { useMonkTheme, changeAlpha } from '@monkvision/common'; +import { ColorProp, InteractiveColors, InteractiveStatus } from '@monkvision/types'; +import { sizes, styles } from './SwitchButton.styles'; + +/** + * The size of a SwitchButton. + */ +export type SwitchButtonSize = 'normal' | 'small'; + +/** + * Props accepted by the SwitchButton component. + */ +export interface SwitchButtonProps { + /** + * The size of the button. Normal buttons are bigger and have their icon and labels inside the button. Small buttons + * are smaller, accept no label and have their icon inside the knob. + * + * @default normal + */ + size?: SwitchButtonSize; + /** + * Boolean used to control the SwitchButton. Set to `true` to make the Button switched on and `false` for off. + * + * @default false + */ + checked?: boolean; + /** + * Callback called when the SwitchButton is switched. The value passed as the first parameter is the result `checked` + * value. + */ + onSwitch?: (value: boolean) => void; + /** + * Boolean indicating if the button is disabled or not. + * + * @default false + */ + disabled?: boolean; + /** + * Primary color (background and knob overlay color) of the button when it is checked. + */ + checkedPrimaryColor?: ColorProp; + /** + * Secondary color (knob, labels and icons color) of the button when it is checked. + */ + checkedSecondaryColor?: ColorProp; + /** + * Primary color (background and knob overlay color) of the button when it is unchecked. + */ + uncheckedPrimaryColor?: ColorProp; + /** + * Secondary color (knob, labels and icons color) of the button when it is unchecked. + */ + uncheckedSecondaryColor?: ColorProp; + /** + * Custom label that can be displayed instead of the check icon when the button is checked. This prop is ignored for + * small buttons. + * + * Note : We recommend keeping this label extra short (2 or 3 characters long). + */ + checkedLabel?: string; + /** + * Custom label that can be displayed when the button is unchecked. This prop is ignored for small buttons. + * + * Note : We recommend keeping this label extra short (2 or 3 characters long). + */ + uncheckedLabel?: string; +} + +type SwitchButtonStylesParam = Required< + Pick< + SwitchButtonProps, + | 'size' + | 'checked' + | 'checkedPrimaryColor' + | 'checkedSecondaryColor' + | 'uncheckedPrimaryColor' + | 'uncheckedSecondaryColor' + > +> & { + status: InteractiveStatus; +}; + +interface SwitchButtonStyles { + buttonStyle: CSSProperties; + icon: { + color: ColorProp; + size: number; + style: CSSProperties; + }; + checkedLabelStyle: CSSProperties; + uncheckedLabelStyle: CSSProperties; + knobOverlayStyle: CSSProperties; + knobStyle: CSSProperties; + iconSmall: { + color: ColorProp; + size: number; + style: CSSProperties; + }; +} + +export function useSwitchButtonStyles(params: SwitchButtonStylesParam): SwitchButtonStyles { + const { utils } = useMonkTheme(); + const knobOverlayColors: InteractiveColors = useMemo( + () => ({ + [InteractiveStatus.DEFAULT]: '#00000000', + [InteractiveStatus.HOVERED]: changeAlpha(utils.getColor(params.checkedPrimaryColor), 0.18), + [InteractiveStatus.ACTIVE]: changeAlpha(utils.getColor(params.checkedPrimaryColor), 0.3), + [InteractiveStatus.DISABLED]: '#00000000', + }), + [params.checkedPrimaryColor, utils], + ); + + return useMemo( + () => ({ + buttonStyle: { + ...styles['button'], + ...(params.status === InteractiveStatus.DISABLED ? styles['buttonDisabled'] : {}), + ...(params.size === 'small' ? styles['buttonSmall'] : {}), + backgroundColor: params.checked + ? utils.getColor(params.checkedPrimaryColor) + : utils.getColor(params.uncheckedPrimaryColor), + }, + icon: { + color: utils.getColor(params.checkedSecondaryColor), + size: sizes.normal.iconSize, + style: { + ...styles['icon'], + opacity: params.checked ? 1 : 0, + }, + }, + checkedLabelStyle: { + ...styles['label'], + color: utils.getColor(params.checkedSecondaryColor), + opacity: params.checked ? 1 : 0, + }, + uncheckedLabelStyle: { + ...styles['label'], + color: utils.getColor(params.uncheckedSecondaryColor), + opacity: params.checked ? 0 : 1, + }, + knobOverlayStyle: { + ...styles['knobOverlay'], + ...(params.size === 'small' ? styles['knobOverlaySmall'] : {}), + ...(params.checked ? styles['knobOverlayChecked'] : {}), + ...(params.checked && params.size === 'small' ? styles['knobOverlaySmallChecked'] : {}), + backgroundColor: knobOverlayColors[params.status], + }, + knobStyle: { + ...styles['knob'], + ...(params.size === 'small' ? styles['knobSmall'] : {}), + ...(params.checked ? styles['knobChecked'] : {}), + ...(params.checked && params.size === 'small' ? styles['knobSmallChecked'] : {}), + backgroundColor: params.checked + ? utils.getColor(params.checkedSecondaryColor) + : utils.getColor(params.uncheckedSecondaryColor), + }, + iconSmall: { + color: utils.getColor(params.checkedPrimaryColor), + size: sizes.small.iconSize, + style: { + ...styles['icon'], + opacity: params.checked ? 1 : 0, + }, + }, + }), + [utils, params, knobOverlayColors], + ); +} diff --git a/packages/public/common-ui-web/src/components/SwitchButton/index.ts b/packages/public/common-ui-web/src/components/SwitchButton/index.ts new file mode 100644 index 000000000..e302b0bfe --- /dev/null +++ b/packages/public/common-ui-web/src/components/SwitchButton/index.ts @@ -0,0 +1,2 @@ +export { SwitchButton } from './SwitchButton'; +export { type SwitchButtonProps, type SwitchButtonSize } from './hooks'; diff --git a/packages/public/common-ui-web/src/components/index.ts b/packages/public/common-ui-web/src/components/index.ts index 5f86f4d54..219a92c61 100644 --- a/packages/public/common-ui-web/src/components/index.ts +++ b/packages/public/common-ui-web/src/components/index.ts @@ -3,3 +3,4 @@ export * from './Spinner'; export * from './TakePictureButton'; export * from './DynamicSVG'; export * from './SightOverlay'; +export * from './SwitchButton'; diff --git a/packages/public/common-ui-web/test/components/Button/Button.test.tsx b/packages/public/common-ui-web/test/components/Button/Button.test.tsx index e5b3d55c3..a8a511f38 100644 --- a/packages/public/common-ui-web/test/components/Button/Button.test.tsx +++ b/packages/public/common-ui-web/test/components/Button/Button.test.tsx @@ -4,18 +4,13 @@ mockButtonDependencies(); import '@testing-library/jest-dom'; import { createEvent, fireEvent, render, screen } from '@testing-library/react'; -import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { expectPropsOnChildMock, getNumberFromCSSProperty } from '@monkvision/test-utils'; import { useInteractiveStatus } from '@monkvision/common'; import { Button, Spinner, Icon, IconProps } from '../../../src'; import { InteractiveStatus } from '@monkvision/types'; const BUTTON_TEST_ID = 'monk-btn'; -function getButtonFontSize(): number { - const buttonEl = screen.getByTestId(BUTTON_TEST_ID); - return Number(buttonEl.style.fontSize.substring(0, buttonEl.style.fontSize.length - 2)); -} - describe('Button component', () => { afterEach(() => { jest.clearAllMocks(); @@ -23,18 +18,22 @@ describe('Button component', () => { it('should take the size prop into account', () => { const { unmount, rerender } = render(