From f03c4a2eccba5c5d9500a94ccad6a05cc340600c Mon Sep 17 00:00:00 2001 From: Rajkumar Gaur <52281264+rajgaur98@users.noreply.github.com> Date: Wed, 15 Mar 2023 16:38:58 +0530 Subject: [PATCH] feat(UI): implement ToggleButton component (#48567) --- tools/ui-components/src/index.ts | 1 + .../ui-components/src/toggle-button/index.ts | 2 + .../toggle-button/toggle-button.stories.tsx | 125 ++++++++++++++++++ .../src/toggle-button/toggle-button.test.tsx | 78 +++++++++++ .../src/toggle-button/toggle-button.tsx | 119 +++++++++++++++++ .../ui-components/src/toggle-button/types.ts | 16 +++ 6 files changed, 341 insertions(+) create mode 100644 tools/ui-components/src/toggle-button/index.ts create mode 100644 tools/ui-components/src/toggle-button/toggle-button.stories.tsx create mode 100644 tools/ui-components/src/toggle-button/toggle-button.test.tsx create mode 100644 tools/ui-components/src/toggle-button/toggle-button.tsx create mode 100644 tools/ui-components/src/toggle-button/types.ts diff --git a/tools/ui-components/src/index.ts b/tools/ui-components/src/index.ts index 3adb1653e3b..224b899820f 100644 --- a/tools/ui-components/src/index.ts +++ b/tools/ui-components/src/index.ts @@ -3,3 +3,4 @@ export { Button } from './button'; export { Alert } from './alert'; export { Image } from './image'; export { Table } from './table'; +export { ToggleButton } from './toggle-button'; diff --git a/tools/ui-components/src/toggle-button/index.ts b/tools/ui-components/src/toggle-button/index.ts new file mode 100644 index 00000000000..95722708c24 --- /dev/null +++ b/tools/ui-components/src/toggle-button/index.ts @@ -0,0 +1,2 @@ +export { ToggleButton } from './toggle-button'; +export type { ToggleButtonProps } from './types'; diff --git a/tools/ui-components/src/toggle-button/toggle-button.stories.tsx b/tools/ui-components/src/toggle-button/toggle-button.stories.tsx new file mode 100644 index 00000000000..886bd928857 --- /dev/null +++ b/tools/ui-components/src/toggle-button/toggle-button.stories.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { Story } from '@storybook/react'; +import { ToggleButton, ToggleButtonProps } from '.'; + +const story = { + title: 'Example/ToggleButton', + component: ToggleButton, + parameters: { + controls: { + include: [ + 'children', + 'bsStyle', + 'bsSize', + 'disabled', + 'checked', + 'onChange', + 'value', + 'name' + ] + } + }, + argTypes: { + bsStyle: { + options: ['primary'] + }, + bsSize: { + options: ['small', 'medium', 'large'] + }, + disabled: { + options: [true, false], + control: { type: 'radio' } + }, + checked: { + options: [true, false], + control: { type: 'radio' } + }, + onChange: { + action: 'changed' + }, + value: { + type: { name: 'string' } + }, + name: { + type: { name: 'string' } + } + } +}; + +const Template: Story = args => { + return ; +}; + +export const Default = Template.bind({}); +Default.args = { + children: 'Off' +}; + +export const Checked = Template.bind({}); +Checked.args = { + checked: true, + children: 'On', + value: 'Value' +}; + +export const Large = Template.bind({}); +Large.args = { + bsSize: 'large', + children: 'Off' +}; + +export const Medium = Template.bind({}); +Medium.args = { + bsSize: 'medium', + children: 'Off' +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + children: 'Off', + disabled: true +}; + +export const RadioChecked = Template.bind({}); +RadioChecked.args = { + type: 'radio', + children: 'On', + value: 'radio', + name: 'radio', + checked: true +}; + +export const RadioUnchecked = Template.bind({}); +RadioUnchecked.args = { + type: 'radio', + children: 'Off', + value: 'radio', + name: 'radio' +}; + +export const InsideToggleGroup = (): JSX.Element => { + const [checked, setChecked] = useState(true); + + return ( + <> + { + setChecked(checked); + }} + > + On + + { + setChecked(!checked); + }} + > + Off + + + ); +}; + +export default story; diff --git a/tools/ui-components/src/toggle-button/toggle-button.test.tsx b/tools/ui-components/src/toggle-button/toggle-button.test.tsx new file mode 100644 index 00000000000..ce94eb0b06a --- /dev/null +++ b/tools/ui-components/src/toggle-button/toggle-button.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ToggleButton } from '.'; + +describe('', () => { + it('should render the toggle button and text', () => { + render(On); + + expect(screen.getByRole('button', { name: /on/i })).toBeInTheDocument(); + }); + + it('should call onChange when clicked', () => { + const onChange = jest.fn(); + render(On); + + userEvent.click(screen.getByRole('button', { name: /on/i })); + + expect(onChange).toBeCalledTimes(1); + }); + + it('should be checked if checked prop is true', () => { + render( + + On + + ); + + expect(screen.getByRole('radio')).toBeChecked(); + }); + + it('should be unchecked if checked prop is false', () => { + render( + + On + + ); + + expect(screen.getByRole('radio')).not.toBeChecked(); + }); + + it('should be aria-disabled if disabled prop is true', () => { + render(On); + + expect(screen.getByRole('button', { name: /on/i })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('should not trigger onChange if disabled prop is true', () => { + const onChange = jest.fn(); + render( + + On + + ); + + userEvent.click(screen.getByRole('button', { name: /on/i })); + + expect(onChange).not.toBeCalled(); + }); + + it('should have value property if radio', () => { + render( +
+ + On + +
+ ); + + expect(screen.getByRole('form')).toHaveFormValues({ + radio: 'value' + }); + }); +}); diff --git a/tools/ui-components/src/toggle-button/toggle-button.tsx b/tools/ui-components/src/toggle-button/toggle-button.tsx new file mode 100644 index 00000000000..bea3174a56a --- /dev/null +++ b/tools/ui-components/src/toggle-button/toggle-button.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { ButtonSize, ToggleButtonProps } from './types'; + +const defaultClassNames = [ + 'relative', + 'border-3', + 'text-center', + 'inline-block', + 'cursor-pointer', + 'border-foreground-secondary', + 'focus:outline-none', // Hide the default browser outline + 'focus:ring', + 'focus:ring-focus-outline-color', + 'focus-within:ring', + 'focus-within:ring-focus-outline-color', + 'aria-disabled:cursor-not-allowed', + 'aria-disabled:opacity-50', + 'ml-[-3px]', + 'first:ml-0' +]; + +const computeClassNames = ({ + bsSize, + checked, + disabled +}: { + bsSize: ButtonSize; + checked?: boolean; + disabled?: boolean; +}) => { + const classNames = [ + ...defaultClassNames, + ...(checked + ? ['cursor-default', 'bg-foreground-primary', 'text-background-primary'] + : ['bg-background-quaternary', 'text-foreground-secondary']), + ...(disabled + ? ['active:before:hidden'] + : [ + 'active:before:w-full', + 'active:before:h-full', + 'active:before:absolute', + 'active:before:inset-0', + 'active:before:border-3', + 'active:before:border-transparent', + 'active:before:bg-gray-900', + 'active:before:opacity-20', + 'dark:hover:bg-background-primary', + 'dark:hover:text-foreground-primary', + ...(checked + ? [ + 'hover:bg-background-quaternary', + 'hover:text-foreground-secondary' + ] + : ['hover:bg-foreground-primary', 'hover:text-background-primary']) + ]) + ]; + + switch (bsSize) { + case 'large': + classNames.push('px-8 py-2.5 text-lg'); + break; + case 'medium': + classNames.push('px-6 py-1.5 text-md'); + break; + // default size is 'small' + default: + classNames.push('px-5 py-1 text-sm'); + } + + return classNames.join(' '); +}; + +export const ToggleButton = ({ + bsSize = 'small', + type = 'button', + disabled, + children, + checked, + onChange, + value, + name +}: ToggleButtonProps): JSX.Element => { + const classNames = computeClassNames({ bsSize, disabled, checked }); + + const handleChange = () => { + if (!disabled && onChange) { + onChange(true); + } + }; + + if (type === 'radio') { + return ( + + ); + } + + return ( + + ); +}; diff --git a/tools/ui-components/src/toggle-button/types.ts b/tools/ui-components/src/toggle-button/types.ts new file mode 100644 index 00000000000..9b1ab05bceb --- /dev/null +++ b/tools/ui-components/src/toggle-button/types.ts @@ -0,0 +1,16 @@ +export type ButtonStyle = 'primary' | 'danger'; + +export type ButtonSize = 'small' | 'medium' | 'large'; + +export interface ToggleButtonProps { + children: React.ReactNode; + bsSize?: ButtonSize; + bsStyle?: ButtonStyle; + disabled?: boolean; + checked?: boolean; + onChange?: (value: boolean) => void; + className?: string; + value?: string; + name?: string; + type?: 'button' | 'radio'; +}