├── .nvmrc ├── .gitattributes ├── .npmrc ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── ci.yml ├── src ├── index.ts ├── hooks │ ├── usePrevious.ts │ ├── useFirstMountState.ts │ └── useAnimationDuration.ts ├── __tests__ │ ├── hooks │ │ ├── useFirstMountState.ts │ │ ├── usePrevious.ts │ │ └── useAnimationDuration.ts │ ├── HeartSwitch.tsx │ └── __snapshots__ │ │ └── HeartSwitch.tsx.snap ├── stories │ └── HeartSwitch.stories.tsx ├── style.ts └── HeartSwitch.tsx ├── .prettierrc.json ├── .commitlintrc.json ├── .husky ├── pre-commit └── commit-msg ├── .gitignore ├── tsconfig.eslint.json ├── .editorconfig ├── .lintstagedrc.json ├── .storybook ├── preview.ts └── main.ts ├── jest.config.json ├── .releaserc.json ├── scripts └── build.ts ├── tsconfig.json ├── LICENSE ├── .eslintrc.json ├── CHANGELOG.md ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @anatoliygatt 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HeartSwitch'; 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | node_modules/ 4 | coverage/ 5 | dist/ 6 | storybook-static/ 7 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["scripts/**/*", "src/**/*"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | commit-message: 8 | prefix: 'fix' 9 | prefix-development: 'chore' 10 | include: 'scope' 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 80 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function usePrevious(state: T): T | undefined { 4 | const ref = useRef(); 5 | 6 | useEffect(() => { 7 | ref.current = state; 8 | }); 9 | 10 | return ref.current; 11 | } 12 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx}": [ 3 | "bash -c tsc --emitDeclarationOnly false --noEmit", 4 | "eslint --fix", 5 | "prettier --write", 6 | "jest --bail --findRelatedTests" 7 | ], 8 | "*.{json,yml,md}": "prettier --write", 9 | "package.json": "sort-package-json" 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/useFirstMountState.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | export function useFirstMountState(): boolean { 4 | const isFirst = useRef(true); 5 | 6 | if (isFirst.current) { 7 | isFirst.current = false; 8 | return true; 9 | } 10 | 11 | return isFirst.current; 12 | } 13 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Parameters } from '@storybook/api'; 2 | 3 | export const parameters: Parameters = { 4 | actions: { argTypesRegex: '^on[A-Z].*' }, 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/, 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "clearMocks": true, 3 | "coverageDirectory": "coverage", 4 | "preset": "ts-jest", 5 | "setupFilesAfterEnv": [ 6 | "@testing-library/jest-dom/extend-expect", 7 | "jest-axe/extend-expect" 8 | ], 9 | "snapshotSerializers": ["@emotion/jest/serializer"], 10 | "testEnvironment": "jsdom" 11 | } 12 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "conventionalcommits", 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "@semantic-release/changelog", 7 | [ 8 | "@semantic-release/exec", 9 | { 10 | "prepareCmd": "prettier --write CHANGELOG.md" 11 | } 12 | ], 13 | "@semantic-release/npm", 14 | "@semantic-release/github", 15 | "@semantic-release/git" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/__tests__/hooks/useFirstMountState.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { useFirstMountState } from '../../hooks/useFirstMountState'; 3 | 4 | describe('useFirstMountState', () => { 5 | test('returns true on the first render and false on the subsequent ones', () => { 6 | const { result, rerender } = renderHook(() => useFirstMountState()); 7 | expect(result.current).toBe(true); 8 | rerender(); 9 | expect(result.current).toBe(false); 10 | rerender(); 11 | expect(result.current).toBe(false); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild'; 2 | import type { BuildOptions } from 'esbuild'; 3 | import * as pkg from '../package.json'; 4 | 5 | const commonBuildOptions: BuildOptions = { 6 | bundle: true, 7 | entryPoints: ['src/index.ts'], 8 | external: [...Object.keys(pkg.peerDependencies)], 9 | minify: true, 10 | sourcemap: true, 11 | }; 12 | 13 | Promise.all([ 14 | build({ 15 | ...commonBuildOptions, 16 | format: 'cjs', 17 | outdir: 'dist/cjs', 18 | }), 19 | build({ 20 | ...commonBuildOptions, 21 | format: 'esm', 22 | outdir: 'dist/esm', 23 | splitting: true, 24 | }), 25 | ]).catch(() => process.exit(1)); 26 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react/types'; 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'], 6 | framework: '@storybook/react', 7 | core: { 8 | builder: 'webpack5', 9 | }, 10 | typescript: { 11 | check: true, 12 | checkOptions: { 13 | typescript: { 14 | configOverwrite: { 15 | include: ['src/**/stories/**/*'], 16 | exclude: ['node_modules'], 17 | }, 18 | }, 19 | }, 20 | }, 21 | }; 22 | 23 | module.exports = config; 24 | -------------------------------------------------------------------------------- /src/__tests__/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { usePrevious } from '../../hooks/usePrevious'; 3 | 4 | describe('usePrevious', () => { 5 | test('returns previous state after each update', () => { 6 | const { result, rerender } = renderHook(({ state }) => usePrevious(state), { 7 | initialProps: { state: 0 }, 8 | }); 9 | expect(result.current).toBeUndefined(); 10 | rerender({ state: 1 }); 11 | expect(result.current).toBe(0); 12 | rerender({ state: 2 }); 13 | expect(result.current).toBe(1); 14 | rerender({ state: 3 }); 15 | expect(result.current).toBe(2); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["esnext", "dom"], 5 | "jsx": "react", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "declaration": true, 9 | "declarationMap": true, 10 | "emitDeclarationOnly": true, 11 | "declarationDir": "dist/types", 12 | "isolatedModules": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "noUncheckedIndexedAccess": true, 21 | "noImplicitOverride": true, 22 | "noPropertyAccessFromIndexSignature": true, 23 | "allowUnusedLabels": false, 24 | "allowUnreachableCode": false, 25 | "skipLibCheck": true 26 | }, 27 | "include": ["src/**/*"], 28 | "exclude": ["src/**/__tests__/**/*", "src/**/stories/**/*"] 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Anatoliy Gatt 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/hooks/useAnimationDuration.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { useFirstMountState } from './useFirstMountState'; 3 | import { usePrevious } from './usePrevious'; 4 | 5 | export function useAnimationDuration( 6 | state: T, 7 | defaultAnimationDurationInMs: number 8 | ): number { 9 | const isFirstMount = useFirstMountState(); 10 | const previousState = usePrevious(state); 11 | const [animationDuration, setAnimationDuration] = useState( 12 | defaultAnimationDurationInMs 13 | ); 14 | const timeoutId = useRef>(); 15 | 16 | useEffect(() => { 17 | if (state !== previousState) { 18 | setAnimationDuration(0); 19 | if (timeoutId.current) { 20 | clearTimeout(timeoutId.current); 21 | } 22 | timeoutId.current = setTimeout(() => { 23 | setAnimationDuration(defaultAnimationDurationInMs); 24 | timeoutId.current = undefined; 25 | }, defaultAnimationDurationInMs); 26 | } 27 | }, [state, previousState, defaultAnimationDurationInMs]); 28 | 29 | if (isFirstMount) { 30 | return 0; 31 | } 32 | return animationDuration; 33 | } 34 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 12, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true 8 | }, 9 | "project": ["./tsconfig.eslint.json"] 10 | }, 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 15 | "plugin:import/recommended", 16 | "plugin:import/typescript", 17 | "plugin:react/recommended", 18 | "plugin:react/jsx-runtime", 19 | "plugin:react-hooks/recommended", 20 | "plugin:jsx-a11y/recommended", 21 | "plugin:prettier/recommended" 22 | ], 23 | "settings": { 24 | "react": { 25 | "version": "detect" 26 | } 27 | }, 28 | "env": { 29 | "browser": true, 30 | "es2021": true 31 | }, 32 | "rules": { 33 | "no-fallthrough": "off", 34 | "no-unused-labels": "off", 35 | "@typescript-eslint/no-unused-vars": "off", 36 | "import/no-unresolved": ["error", { "caseSensitive": false }], 37 | "react/prop-types": "off" 38 | }, 39 | "overrides": [ 40 | { 41 | "files": [ 42 | "**/__tests__/**/*.[jt]s?(x)", 43 | "**/?(*.)+(spec|test).[jt]s?(x)" 44 | ], 45 | "extends": [ 46 | "plugin:jest/recommended", 47 | "plugin:testing-library/react", 48 | "plugin:jest-dom/recommended" 49 | ] 50 | }, 51 | { 52 | "files": ["src/**/*.stories.@(js|jsx|ts|tsx)"], 53 | "extends": ["plugin:storybook/recommended"] 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /src/__tests__/hooks/useAnimationDuration.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { useAnimationDuration } from '../../hooks/useAnimationDuration'; 3 | 4 | describe('useAnimationDuration', () => { 5 | test('responds to a change in state', async () => { 6 | const { result, waitForNextUpdate, rerender } = renderHook( 7 | ({ state, defaultAnimationDurationInMs }) => 8 | useAnimationDuration(state, defaultAnimationDurationInMs), 9 | { initialProps: { state: 'sm', defaultAnimationDurationInMs: 350 } } 10 | ); 11 | 12 | expect(result.current).toBe(0); 13 | await waitForNextUpdate(); 14 | expect(result.current).toBe(350); 15 | 16 | rerender({ state: 'md', defaultAnimationDurationInMs: 350 }); 17 | 18 | expect(result.current).toBe(0); 19 | await waitForNextUpdate(); 20 | expect(result.current).toBe(350); 21 | }); 22 | 23 | test('reschedules the update when the state is changed before the update is fired', async () => { 24 | const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); 25 | 26 | const { result, waitForNextUpdate, rerender } = renderHook( 27 | ({ state, defaultAnimationDurationInMs }) => 28 | useAnimationDuration(state, defaultAnimationDurationInMs), 29 | { initialProps: { state: 'sm', defaultAnimationDurationInMs: 350 } } 30 | ); 31 | 32 | expect(clearTimeoutSpy).not.toHaveBeenCalled(); 33 | expect(result.current).toBe(0); 34 | 35 | rerender({ state: 'md', defaultAnimationDurationInMs: 350 }); 36 | 37 | expect(clearTimeoutSpy).toHaveBeenCalled(); 38 | expect(result.current).toBe(0); 39 | await waitForNextUpdate(); 40 | expect(result.current).toBe(350); 41 | 42 | clearTimeoutSpy.mockRestore(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/stories/HeartSwitch.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ComponentMeta, ComponentStory } from '@storybook/react'; 3 | import { action } from '@storybook/addon-actions'; 4 | import { HeartSwitch } from '../HeartSwitch'; 5 | 6 | export default { 7 | title: 'HeartSwitch', 8 | component: HeartSwitch, 9 | parameters: { 10 | controls: { disabled: true }, 11 | actions: { disabled: true }, 12 | a11y: { disable: true }, 13 | }, 14 | } as ComponentMeta; 15 | 16 | const Template: ComponentStory = (args) => ( 17 | 18 | ); 19 | 20 | export const Playground = Template.bind({}); 21 | Playground.args = { 22 | size: 'lg', 23 | inactiveTrackFillColor: '#ffffff', 24 | inactiveTrackStrokeColor: '#d1d1d1', 25 | activeTrackFillColor: '#ff708f', 26 | activeTrackStrokeColor: '#ff4e74', 27 | disabledTrackFillColor: '#f2f2f2', 28 | disabledTrackStrokeColor: '#d1d1d1', 29 | invalidTrackFillColor: '#ffffff', 30 | invalidTrackStrokeColor: '#d1d1d1', 31 | inactiveThumbColor: '#ffffff', 32 | activeThumbColor: '#ffffff', 33 | disabledThumbColor: '#ffffff', 34 | invalidThumbColor: '#ffffff', 35 | thumbShadowColor: 'rgb(23 23 23 / 0.25)', 36 | thumbFocusRingColor: 'rgb(59 130 246 / 0.5)', 37 | autoFocus: false, 38 | defaultChecked: false, 39 | disabled: false, 40 | form: 'complianceForm', 41 | name: 'acceptTermsAndConditions', 42 | required: false, 43 | value: 'true', 44 | id: 'heart-switch', 45 | title: 'Accept Terms and Conditions', 46 | tabIndex: 0, 47 | 'aria-disabled': 'false', 48 | 'aria-label': 'Accept Terms and Conditions', 49 | onBlur: action('onBlur'), 50 | onChange: action('onChange'), 51 | onFocus: action('onFocus'), 52 | onInvalid: action('onInvalid'), 53 | }; 54 | Playground.parameters = { 55 | controls: { disabled: false }, 56 | actions: { disabled: false }, 57 | a11y: { disable: false }, 58 | }; 59 | 60 | export const Default = Template.bind({}); 61 | 62 | export const Disabled = Template.bind({}); 63 | Disabled.args = { 64 | size: 'md', 65 | disabled: true, 66 | }; 67 | 68 | export const Customized = Template.bind({}); 69 | Customized.args = { 70 | size: 'lg', 71 | inactiveTrackFillColor: '#cffafe', 72 | inactiveTrackStrokeColor: '#22d3ee', 73 | activeTrackFillColor: '#06b6d4', 74 | activeTrackStrokeColor: '#0891b2', 75 | inactiveThumbColor: '#ecfeff', 76 | activeThumbColor: '#ecfeff', 77 | }; 78 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v2 15 | - name: Install Node.js 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: '16' 19 | cache: 'npm' 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Run build 23 | run: npm run build 24 | - name: Upload build artifacts 25 | uses: actions/upload-artifact@v2 26 | with: 27 | name: dist 28 | path: dist 29 | - name: Run build-storybook 30 | run: npm run build-storybook 31 | - name: Upload storybook-static 32 | uses: actions/upload-artifact@v2 33 | with: 34 | name: storybook-static 35 | path: storybook-static 36 | lint: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Check out repository 40 | uses: actions/checkout@v2 41 | - name: Install Node.js 42 | uses: actions/setup-node@v2 43 | with: 44 | node-version: '16' 45 | cache: 'npm' 46 | - name: Install dependencies 47 | run: npm ci 48 | - name: Run lint 49 | run: npm run lint 50 | test: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Check out repository 54 | uses: actions/checkout@v2 55 | - name: Install Node.js 56 | uses: actions/setup-node@v2 57 | with: 58 | node-version: '16' 59 | cache: 'npm' 60 | - name: Install dependencies 61 | run: npm ci 62 | - name: Run test:coverage 63 | run: npm run test:coverage 64 | - name: Upload coverage report 65 | uses: actions/upload-artifact@v2 66 | with: 67 | name: coverage 68 | path: coverage 69 | deploy-storybook: 70 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 71 | needs: [build, lint, test] 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: Check out repository 75 | uses: actions/checkout@v2 76 | - name: Download storybook-static 77 | uses: actions/download-artifact@v2 78 | with: 79 | name: storybook-static 80 | path: storybook-static 81 | - name: Deploy to Netlify 82 | uses: netlify/actions/cli@master 83 | with: 84 | args: deploy --dir=storybook-static --prod 85 | env: 86 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 87 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 88 | release: 89 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 90 | needs: [build, lint, test] 91 | runs-on: ubuntu-latest 92 | steps: 93 | - name: Check out repository 94 | uses: actions/checkout@v2 95 | - name: Install Node.js 96 | uses: actions/setup-node@v2 97 | with: 98 | node-version: '16' 99 | cache: 'npm' 100 | - name: Install dependencies 101 | run: npm ci 102 | - name: Download build artifacts 103 | uses: actions/download-artifact@v2 104 | with: 105 | name: dist 106 | path: dist 107 | - name: Run semantic-release 108 | run: npm run semantic-release 109 | env: 110 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 111 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 112 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### [1.0.13](https://github.com/anatoliygatt/heart-switch/compare/v1.0.12...v1.0.13) (2023-03-10) 2 | 3 | ### Bug Fixes 4 | 5 | - **deps:** bump minimist from 1.2.5 to 1.2.7 ([55a6e1d](https://github.com/anatoliygatt/heart-switch/commit/55a6e1d5fd172b0e987a53c7169192f414b3f80e)) 6 | 7 | ### [1.0.12](https://github.com/anatoliygatt/heart-switch/compare/v1.0.11...v1.0.12) (2023-02-18) 8 | 9 | ### Bug Fixes 10 | 11 | - **README:** add a link to Aaron Iker's codepen ([a6a7f74](https://github.com/anatoliygatt/heart-switch/commit/a6a7f747a1af2e6d7689c5430ad935b329f17bcd)) 12 | 13 | ### [1.0.11](https://github.com/anatoliygatt/heart-switch/compare/v1.0.10...v1.0.11) (2023-01-16) 14 | 15 | ### Bug Fixes 16 | 17 | - **deps:** bump json5 from 1.0.1 to 1.0.2 ([e141d70](https://github.com/anatoliygatt/heart-switch/commit/e141d70523e2010c7ee2f6a0f9ff18d0cfdd7dea)) 18 | 19 | ### [1.0.10](https://github.com/anatoliygatt/heart-switch/compare/v1.0.9...v1.0.10) (2022-12-30) 20 | 21 | ### Bug Fixes 22 | 23 | - **deps:** bump qs and express ([562072a](https://github.com/anatoliygatt/heart-switch/commit/562072adaff0209c5e2012d980d4d05b5e2ef895)) 24 | 25 | ### [1.0.9](https://github.com/anatoliygatt/heart-switch/compare/v1.0.8...v1.0.9) (2022-12-26) 26 | 27 | ### Bug Fixes 28 | 29 | - **deps:** bump decode-uri-component from 0.2.0 to 0.2.2 ([47eefb4](https://github.com/anatoliygatt/heart-switch/commit/47eefb465be2b705fe31c67817af1c0389549139)) 30 | 31 | ### [1.0.8](https://github.com/anatoliygatt/heart-switch/compare/v1.0.7...v1.0.8) (2022-12-26) 32 | 33 | ### Bug Fixes 34 | 35 | - **README:** update github ci workflow status badge url ([da35d29](https://github.com/anatoliygatt/heart-switch/commit/da35d29f5e77e036179e13db716d45270e78766c)) 36 | 37 | ### [1.0.7](https://github.com/anatoliygatt/heart-switch/compare/v1.0.6...v1.0.7) (2022-12-06) 38 | 39 | ### Bug Fixes 40 | 41 | - **deps:** bump loader-utils from 1.4.0 to 1.4.2 ([363966b](https://github.com/anatoliygatt/heart-switch/commit/363966be0e9b7c16762f13fcf8813c0dfdfcb36a)) 42 | 43 | ### [1.0.6](https://github.com/anatoliygatt/heart-switch/compare/v1.0.5...v1.0.6) (2022-09-14) 44 | 45 | ### Bug Fixes 46 | 47 | - **deps-peer:** allow usage of react@^18.0.0 ([1150656](https://github.com/anatoliygatt/heart-switch/commit/11506569fa313ef4b4acaef2083b56e99db5d726)) 48 | 49 | ### [1.0.5](https://github.com/anatoliygatt/heart-switch/compare/v1.0.4...v1.0.5) (2022-09-10) 50 | 51 | ### Bug Fixes 52 | 53 | - **deps:** bump terser from 4.8.0 to 4.8.1 ([5be056f](https://github.com/anatoliygatt/heart-switch/commit/5be056f3f12dfa1cc5483dfedfc624334fd4637b)) 54 | 55 | ### [1.0.4](https://github.com/anatoliygatt/heart-switch/compare/v1.0.3...v1.0.4) (2022-08-30) 56 | 57 | ### Bug Fixes 58 | 59 | - **deps:** bump npm from 8.5.0 to 8.13.2 ([613d235](https://github.com/anatoliygatt/heart-switch/commit/613d235a82b64ac4e6757ca8a6362c5a2607c96f)) 60 | 61 | ### [1.0.3](https://github.com/anatoliygatt/heart-switch/compare/v1.0.2...v1.0.3) (2022-07-04) 62 | 63 | ### Bug Fixes 64 | 65 | - **deps:** bump semver-regex from 3.1.3 to 3.1.4 ([7fa58ce](https://github.com/anatoliygatt/heart-switch/commit/7fa58ce8d78e95b4b241529b2cda153df1ffe6ec)) 66 | 67 | ### [1.0.2](https://github.com/anatoliygatt/heart-switch/compare/v1.0.1...v1.0.2) (2022-02-18) 68 | 69 | ### Performance Improvements 70 | 71 | - reduce bundle size by removing dependency on react-use ([0709ec8](https://github.com/anatoliygatt/heart-switch/commit/0709ec8cbd87e666ef28e1c94e57e155df062520)) 72 | 73 | ### [1.0.1](https://github.com/anatoliygatt/heart-switch/compare/v1.0.0...v1.0.1) (2022-02-17) 74 | 75 | ### Bug Fixes 76 | 77 | - **HeartSwitch:** disable tap highlight color in Safari on iOS ([5dc8754](https://github.com/anatoliygatt/heart-switch/commit/5dc875416486d188f6da0b10cf1785c90f2fac9d)) 78 | 79 | ## 1.0.0 (2022-02-16) 80 | 81 | ### Features 82 | 83 | - add HeartSwitch ([9610186](https://github.com/anatoliygatt/heart-switch/commit/9610186e5d67c07f96db2c561c70d890f31dc44e)) 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@anatoliygatt/heart-switch", 3 | "version": "1.0.13", 4 | "description": "A heart-shaped toggle switch component for React.", 5 | "keywords": [ 6 | "react", 7 | "component", 8 | "react-component", 9 | "toggle", 10 | "switch", 11 | "toggle-switch", 12 | "accessible", 13 | "javascript", 14 | "typescript" 15 | ], 16 | "homepage": "https://github.com/anatoliygatt/heart-switch#readme", 17 | "bugs": "https://github.com/anatoliygatt/heart-switch/issues", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/anatoliygatt/heart-switch.git" 21 | }, 22 | "license": "MIT", 23 | "author": "Anatoliy Gatt", 24 | "sideEffects": false, 25 | "main": "dist/cjs/index.js", 26 | "module": "dist/esm/index.js", 27 | "types": "dist/types/index.d.ts", 28 | "files": [ 29 | "dist/" 30 | ], 31 | "scripts": { 32 | "prebuild": "rimraf dist", 33 | "build": "run-s build:*", 34 | "build:esbuild": "ts-node scripts/build.ts", 35 | "build:tsc": "tsc", 36 | "commit": "git-cz", 37 | "lint": "run-s lint:*", 38 | "lint:eslint": "eslint . --ignore-path .gitignore", 39 | "lint:prettier": "prettier --check . --ignore-path .gitignore", 40 | "lint:tsc": "tsc --emitDeclarationOnly false --noEmit", 41 | "prepare": "is-ci || husky install", 42 | "semantic-release": "semantic-release", 43 | "test": "jest", 44 | "test:coverage": "jest --coverage", 45 | "test:watch": "jest --watch", 46 | "storybook": "start-storybook -p 6006", 47 | "build-storybook": "build-storybook" 48 | }, 49 | "config": { 50 | "commitizen": { 51 | "path": "cz-conventional-changelog" 52 | } 53 | }, 54 | "devDependencies": { 55 | "@babel/core": "^7.17.4", 56 | "@commitlint/cli": "^17.0.0", 57 | "@commitlint/config-conventional": "^17.2.0", 58 | "@emotion/jest": "^11.7.1", 59 | "@emotion/react": "^11.7.1", 60 | "@emotion/styled": "^11.6.0", 61 | "@semantic-release/changelog": "^6.0.1", 62 | "@semantic-release/exec": "^6.0.3", 63 | "@semantic-release/git": "^10.0.1", 64 | "@storybook/addon-a11y": "^6.5.10", 65 | "@storybook/addon-actions": "^6.5.10", 66 | "@storybook/addon-essentials": "^6.5.10", 67 | "@storybook/builder-webpack5": "^6.5.10", 68 | "@storybook/manager-webpack5": "^6.5.10", 69 | "@storybook/react": "^6.5.10", 70 | "@testing-library/jest-dom": "^5.16.2", 71 | "@testing-library/react": "^12.1.2", 72 | "@testing-library/react-hooks": "^8.0.1", 73 | "@testing-library/user-event": "^14.2.0", 74 | "@types/jest": "^27.4.0", 75 | "@types/jest-axe": "^3.5.3", 76 | "@types/node": "^17.0.18", 77 | "@types/react": "^17.0.39", 78 | "@types/react-dom": "^17.0.11", 79 | "@typescript-eslint/eslint-plugin": "^5.12.0", 80 | "@typescript-eslint/parser": "^5.12.0", 81 | "babel-loader": "^8.2.3", 82 | "commitizen": "^4.2.4", 83 | "conventional-changelog-conventionalcommits": "^4.6.3", 84 | "esbuild": "^0.15.15", 85 | "eslint": "^8.9.0", 86 | "eslint-config-prettier": "^8.3.0", 87 | "eslint-plugin-import": "^2.25.4", 88 | "eslint-plugin-jest": "^26.1.1", 89 | "eslint-plugin-jest-dom": "^4.0.1", 90 | "eslint-plugin-jsx-a11y": "^6.5.1", 91 | "eslint-plugin-prettier": "^4.0.0", 92 | "eslint-plugin-react": "^7.28.0", 93 | "eslint-plugin-react-hooks": "^4.3.0", 94 | "eslint-plugin-storybook": "^0.6.4", 95 | "eslint-plugin-testing-library": "^5.0.5", 96 | "husky": "^8.0.1", 97 | "is-ci": "^3.0.1", 98 | "jest": "^27.5.1", 99 | "jest-axe": "^6.0.0", 100 | "lint-staged": "^13.1.1", 101 | "npm-run-all": "^4.1.5", 102 | "prettier": "^2.5.1", 103 | "react": "^17.0.2", 104 | "react-dom": "^17.0.2", 105 | "rimraf": "^3.0.2", 106 | "semantic-release": "^19.0.2", 107 | "sort-package-json": "^2.4.1", 108 | "ts-jest": "^27.1.3", 109 | "ts-node": "^10.5.0", 110 | "typescript": "^4.5.5", 111 | "webpack": "^5.69.0" 112 | }, 113 | "peerDependencies": { 114 | "@emotion/react": "^11.0.0", 115 | "@emotion/styled": "^11.0.0", 116 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 117 | }, 118 | "publishConfig": { 119 | "access": "public" 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/style.ts: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { keyframes } from '@emotion/react'; 3 | import type { Keyframes } from '@emotion/react'; 4 | import type { Size, OwnProps } from './HeartSwitch'; 5 | 6 | export const TOGGLE_ANIMATION_DURATION = 350; 7 | 8 | function sizeToScale(size: Size): number { 9 | switch (size) { 10 | case 'sm': 11 | return 1; 12 | case 'md': 13 | return 1.5; 14 | case 'lg': 15 | return 2; 16 | } 17 | } 18 | 19 | const check = (size: Size): Keyframes => keyframes` 20 | 0% { 21 | transform: rotate(30deg); 22 | } 23 | 24 | 25% { 25 | transform: rotate(30deg) translateX(${ 26 | 4.5 * sizeToScale(size) 27 | }px) scaleX(1.1); 28 | } 29 | 30 | 50% { 31 | transform: rotate(30deg) translateX(${9 * sizeToScale(size)}px); 32 | } 33 | 34 | 100% { 35 | transform: rotate(-30deg) translateX(${ 36 | 13.5 * sizeToScale(size) 37 | }px) translateY(${8 * sizeToScale(size)}px); 38 | } 39 | `; 40 | 41 | const uncheck = (size: Size): Keyframes => keyframes` 42 | 0% { 43 | transform: rotate(-30deg) translateX(${ 44 | 13.5 * sizeToScale(size) 45 | }px) translateY(${8 * sizeToScale(size)}px); 46 | } 47 | 48 | 50% { 49 | transform: rotate(30deg) translateX(${9 * sizeToScale(size)}px); 50 | } 51 | 52 | 75% { 53 | transform: rotate(30deg) translateX(${ 54 | 4.5 * sizeToScale(size) 55 | }px) scaleX(1.1); 56 | } 57 | 58 | 100% { 59 | transform: rotate(30deg); 60 | } 61 | `; 62 | 63 | export const StyledHeartSwitch = styled.label>` 64 | position: relative; 65 | display: block; 66 | cursor: pointer; 67 | -webkit-tap-highlight-color: transparent; 68 | 69 | input { 70 | position: absolute; 71 | top: ${(props) => 1 * sizeToScale(props.size)}px; 72 | left: ${(props) => 1 * sizeToScale(props.size)}px; 73 | transition: border-width 50ms, 74 | background-color ${TOGGLE_ANIMATION_DURATION}ms; 75 | appearance: none; 76 | margin: 0; 77 | box-shadow: 0 0 ${(props) => 2 * sizeToScale(props.size)}px 78 | ${(props) => 1 * sizeToScale(props.size)}px 79 | ${(props) => props.thumbShadowColor}; 80 | outline: none; 81 | border: 0 solid ${(props) => props.thumbFocusRingColor}; 82 | border-radius: 50%; 83 | width: ${(props) => 18 * sizeToScale(props.size)}px; 84 | height: ${(props) => 18 * sizeToScale(props.size)}px; 85 | background-color: ${(props) => props.inactiveThumbColor}; 86 | pointer-events: none; 87 | 88 | & + svg { 89 | display: block; 90 | transition: fill ${TOGGLE_ANIMATION_DURATION}ms, 91 | stroke ${TOGGLE_ANIMATION_DURATION}ms; 92 | width: ${(props) => 36 * sizeToScale(props.size)}px; 93 | height: ${(props) => 25 * sizeToScale(props.size)}px; 94 | fill: ${(props) => props.inactiveTrackFillColor}; 95 | stroke: ${(props) => props.inactiveTrackStrokeColor}; 96 | stroke-linejoin: round; 97 | } 98 | 99 | &:checked { 100 | animation-name: ${(props) => check(props.size)}; 101 | animation-timing-function: linear; 102 | animation-fill-mode: forwards; 103 | background-color: ${(props) => props.activeThumbColor}; 104 | 105 | & + svg { 106 | fill: ${(props) => props.activeTrackFillColor}; 107 | stroke: ${(props) => props.activeTrackStrokeColor}; 108 | } 109 | } 110 | 111 | &:not(:checked) { 112 | animation-name: ${(props) => uncheck(props.size)}; 113 | animation-timing-function: linear; 114 | animation-fill-mode: backwards; 115 | } 116 | 117 | &:focus { 118 | border-width: ${(props) => 1 * sizeToScale(props.size)}px; 119 | } 120 | 121 | &:focus:not(:focus-visible) { 122 | border-width: 0; 123 | } 124 | 125 | &:focus-visible { 126 | border-width: ${(props) => 1 * sizeToScale(props.size)}px; 127 | } 128 | 129 | &:invalid { 130 | background-color: ${(props) => props.invalidThumbColor}; 131 | 132 | & + svg { 133 | fill: ${(props) => props.invalidTrackFillColor}; 134 | stroke: ${(props) => props.invalidTrackStrokeColor}; 135 | } 136 | } 137 | } 138 | 139 | &[data-disabled='true'] { 140 | cursor: default; 141 | 142 | input { 143 | background-color: ${(props) => props.disabledThumbColor}; 144 | 145 | & + svg { 146 | fill: ${(props) => props.disabledTrackFillColor}; 147 | stroke: ${(props) => props.disabledTrackStrokeColor}; 148 | } 149 | } 150 | } 151 | `; 152 | -------------------------------------------------------------------------------- /src/HeartSwitch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useAnimationDuration } from './hooks/useAnimationDuration'; 3 | import { TOGGLE_ANIMATION_DURATION, StyledHeartSwitch } from './style'; 4 | 5 | export type Size = 'sm' | 'md' | 'lg'; 6 | 7 | export interface OwnProps { 8 | size?: Size; 9 | inactiveTrackFillColor?: string; 10 | inactiveTrackStrokeColor?: string; 11 | activeTrackFillColor?: string; 12 | activeTrackStrokeColor?: string; 13 | disabledTrackFillColor?: string; 14 | disabledTrackStrokeColor?: string; 15 | invalidTrackFillColor?: string; 16 | invalidTrackStrokeColor?: string; 17 | inactiveThumbColor?: string; 18 | activeThumbColor?: string; 19 | disabledThumbColor?: string; 20 | invalidThumbColor?: string; 21 | thumbShadowColor?: string; 22 | thumbFocusRingColor?: string; 23 | } 24 | 25 | export type InputProps = Pick< 26 | JSX.IntrinsicElements['input'], 27 | | 'autoFocus' 28 | | 'checked' 29 | | 'defaultChecked' 30 | | 'disabled' 31 | | 'form' 32 | | 'name' 33 | | 'required' 34 | | 'value' 35 | | 'id' 36 | | 'title' 37 | | 'tabIndex' 38 | | 'aria-disabled' 39 | | 'aria-label' 40 | | 'aria-describedby' 41 | | 'aria-labelledby' 42 | | 'onBlur' 43 | | 'onChange' 44 | | 'onFocus' 45 | | 'onInvalid' 46 | >; 47 | 48 | export type Props = OwnProps & InputProps; 49 | 50 | export const HeartSwitch = React.forwardRef( 51 | ( 52 | { 53 | size = 'sm', 54 | inactiveTrackFillColor = '#ffffff', 55 | inactiveTrackStrokeColor = '#d1d1d1', 56 | activeTrackFillColor = '#ff708f', 57 | activeTrackStrokeColor = '#ff4e74', 58 | disabledTrackFillColor = '#f2f2f2', 59 | disabledTrackStrokeColor = '#d1d1d1', 60 | invalidTrackFillColor = '#ffffff', 61 | invalidTrackStrokeColor = '#d1d1d1', 62 | inactiveThumbColor = '#ffffff', 63 | activeThumbColor = '#ffffff', 64 | disabledThumbColor = '#ffffff', 65 | invalidThumbColor = '#ffffff', 66 | thumbShadowColor = 'rgb(23 23 23 / 0.25)', 67 | thumbFocusRingColor = 'rgb(59 130 246 / 0.5)', 68 | title, 69 | ...inputProps 70 | }, 71 | ref 72 | ) => { 73 | const animationDuration = useAnimationDuration( 74 | size, 75 | TOGGLE_ANIMATION_DURATION 76 | ); 77 | 78 | const labelProps: Pick< 79 | JSX.IntrinsicElements['label'], 80 | 'title' | 'onClick' 81 | > & { 82 | 'data-disabled': boolean; 83 | } = { 84 | title, 85 | 'data-disabled': 86 | inputProps.disabled || inputProps['aria-disabled'] === 'true', 87 | }; 88 | 89 | if (!inputProps.disabled && inputProps['aria-disabled'] === 'true') { 90 | labelProps.onClick = (event) => { 91 | event.preventDefault(); 92 | }; 93 | } 94 | 95 | return ( 96 | 115 | 122 | 129 | 130 | ); 131 | } 132 | ); 133 | 134 | HeartSwitch.displayName = 'HeartSwitch'; 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | heart-switch Demo 5 |
6 | 7 |
8 | 9 |

heart-switch

10 |

A heart-shaped toggle switch component for React. Inspired by Tore Bernhoft's I heart toggle Dribbble shot and Aaron Iker's Codepen.

11 | 12 |
13 | 14 |

15 | 16 | Github CI Workflow Status 17 | 18 | 19 | NPM Version 20 | 21 | 22 | License 23 | 24 |

25 | 26 |
27 | 28 | ## 📖 Table of Contents 29 | 30 | - [🚀 Getting Started](#-getting-started) 31 | - [⚡️ Quick Start](#%EF%B8%8F-quick-start) 32 | - [💻 Live Demo](#-live-demo) 33 | - [⚙️ Configuration](#%EF%B8%8F-configuration) 34 | - [♿️ Accessibility](#%EF%B8%8F-accessibility) 35 | - [👨🏼‍⚖️ License](#%EF%B8%8F-license) 36 | 37 | ## 🚀 Getting Started 38 | 39 | ### ⚡️ Quick Start 40 | 41 | ```shell 42 | npm install @anatoliygatt/heart-switch @emotion/react @emotion/styled 43 | ``` 44 | 45 | ```jsx 46 | import { useState } from 'react'; 47 | import { HeartSwitch } from '@anatoliygatt/heart-switch'; 48 | 49 | function Example() { 50 | const [checked, setChecked] = useState(false); 51 | return ( 52 | { 62 | setChecked(event.target.checked); 63 | }} 64 | /> 65 | ); 66 | } 67 | ``` 68 | 69 | ### 💻 Live Demo 70 | 71 | [![Open in CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/demo-for-anatoliygatt-heart-switch-cds5p) 72 | 73 | ## ⚙️ Configuration 74 | 75 | `HeartSwitch` supports the following props: 76 | 77 | | Prop | Type | Default value | Description | 78 | | ------------------------ | ------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | 79 | | size | string | `sm` | The size of the toggle switch. There are 3 available sizes:
  • `sm` — 36x25px
  • `md` — 54x37.5px
  • `lg` — 72x50px
| 80 | | inactiveTrackFillColor | string | `#ffffff` | The fill color of the track when the toggle switch is in an inactive/off state. | 81 | | inactiveTrackStrokeColor | string | `#d1d1d1` | The stroke color of the track when the toggle switch is in an inactive/off state. | 82 | | activeTrackFillColor | string | `#ff708f` | The fill color of the track when the toggle switch is in an active/on state. | 83 | | activeTrackStrokeColor | string | `#ff4e74` | The stroke color of the track when the toggle switch is in an active/on state. | 84 | | disabledTrackFillColor | string | `#f2f2f2` | The fill color of the track when the toggle switch is in a disabled state. | 85 | | disabledTrackStrokeColor | string | `#d1d1d1` | The stroke color of the track when the toggle switch is in a disabled state. | 86 | | invalidTrackFillColor | string | `#ffffff` | The fill color of the track when the toggle switch is in an invalid state. | 87 | | invalidTrackStrokeColor | string | `#d1d1d1` | The stroke color of the track when the toggle switch is in an invalid state. | 88 | | inactiveThumbColor | string | `#ffffff` | The color of the thumb when the toggle switch is in an inactive/off state. | 89 | | activeThumbColor | string | `#ffffff` | The color of the thumb when the toggle switch is in an active/on state. | 90 | | disabledThumbColor | string | `#ffffff` | The color of the thumb when the toggle switch is in a disabled state. | 91 | | invalidThumbColor | string | `#ffffff` | The color of the thumb when the toggle switch is in an invalid state. | 92 | | thumbShadowColor | string | `rgb(23 23 23 / 0.25)` | The color of the thumb's shadow. | 93 | | thumbFocusRingColor | string | `rgb(59 130 246 / 0.5)` | The color of the thumb's focus ring. | 94 | 95 | The majority of the native `` attributes are also supported; namely, `autoFocus`, `checked`, `defaultChecked`, `disabled`, `form`, `name`, `required`, `value`, `id`, `title`, `tabIndex`, `aria-disabled`, `aria-label`, `aria-describedby`, `aria-labelledby`, `onBlur`, `onChange`, `onFocus` and `onInvalid`. 96 | 97 | `HeartSwitch` also supports [ref forwarding](https://reactjs.org/docs/forwarding-refs.html). If `ref` is passed, it will be forwarded to the underlying `` element. It can be especially useful when we want to use `HeartSwitch` as an [uncontrolled component](https://reactjs.org/docs/uncontrolled-components.html). 98 | 99 | ## ♿️ Accessibility 100 | 101 | In order to comply with the web accessibility standards, we must make use of an `aria-label` or `aria-labelledby` attribute, like so: 102 | 103 | ```jsx 104 | function AccessibleExample() { 105 | return ; 106 | } 107 | ``` 108 | 109 | Also, it is recommended to use an `aria-disabled` instead of a `disabled` attribute to make `HeartSwitch` immutable but focusable, like so: 110 | 111 | ```jsx 112 | function AccessibleAndDisabledExample() { 113 | return ( 114 | 118 | ); 119 | } 120 | ``` 121 | 122 | ## 👨🏼‍⚖️ License 123 | 124 | [MIT](https://github.com/anatoliygatt/heart-switch/blob/master/LICENSE) 125 | -------------------------------------------------------------------------------- /src/__tests__/HeartSwitch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { axe } from 'jest-axe'; 5 | import { HeartSwitch } from '../HeartSwitch'; 6 | 7 | jest.mock('../hooks/useAnimationDuration', () => { 8 | return { 9 | useAnimationDuration: jest.fn(() => 350), 10 | }; 11 | }); 12 | 13 | describe('HeartSwitch', () => { 14 | test('renders without crashing', () => { 15 | expect(() => { 16 | render(); 17 | }).not.toThrowError(); 18 | }); 19 | 20 | test('renders into the document', () => { 21 | render(); 22 | expect(screen.getByTestId('heart-switch')).toBeInTheDocument(); 23 | }); 24 | 25 | test('renders with the default size and colors', () => { 26 | render(); 27 | expect(screen.getByTestId('heart-switch')).toMatchSnapshot(); 28 | }); 29 | 30 | test('renders with the size="md" and default colors', () => { 31 | render(); 32 | expect(screen.getByTestId('heart-switch')).toMatchSnapshot(); 33 | }); 34 | 35 | test('renders with the size="lg" and custom colors', () => { 36 | render( 37 | 46 | ); 47 | expect(screen.getByTestId('heart-switch')).toMatchSnapshot(); 48 | }); 49 | 50 | test('renders in a focused state when autoFocus=true', () => { 51 | const onFocus = jest.fn(); 52 | // eslint-disable-next-line jsx-a11y/no-autofocus 53 | render(); 54 | expect(screen.getByRole('switch')).toHaveFocus(); 55 | expect(onFocus).toHaveBeenCalled(); 56 | }); 57 | 58 | test('checks/unchecks on click when uncontrolled', async () => { 59 | render(); 60 | expect(screen.getByRole('switch')).not.toBeChecked(); 61 | await userEvent.click(screen.getByTestId('heart-switch')); 62 | expect(screen.getByRole('switch')).toBeChecked(); 63 | await userEvent.click(screen.getByTestId('heart-switch')); 64 | expect(screen.getByRole('switch')).not.toBeChecked(); 65 | }); 66 | 67 | test('checks/unchecks on click when controlled', async () => { 68 | function ControlledHeartSwitch() { 69 | const [checked, setChecked] = React.useState(false); 70 | return ( 71 | { 74 | setChecked(event.target.checked); 75 | }} 76 | /> 77 | ); 78 | } 79 | 80 | render(); 81 | expect(screen.getByRole('switch')).not.toBeChecked(); 82 | await userEvent.click(screen.getByTestId('heart-switch')); 83 | expect(screen.getByRole('switch')).toBeChecked(); 84 | await userEvent.click(screen.getByTestId('heart-switch')); 85 | expect(screen.getByRole('switch')).not.toBeChecked(); 86 | }); 87 | 88 | test('renders in a checked state when defaultChecked=true', () => { 89 | render(); 90 | expect(screen.getByRole('switch')).toBeChecked(); 91 | }); 92 | 93 | test('renders in a disabled state when disabled=true', () => { 94 | render(); 95 | expect(screen.getByTestId('heart-switch')).toHaveAttribute( 96 | 'data-disabled', 97 | 'true' 98 | ); 99 | expect(screen.getByRole('switch')).toBeDisabled(); 100 | }); 101 | 102 | test('renders with a form attribute when form="complianceForm"', () => { 103 | render(); 104 | expect(screen.getByRole('switch')).toHaveAttribute( 105 | 'form', 106 | 'complianceForm' 107 | ); 108 | }); 109 | 110 | test('renders with a name attribute when name="acceptTermsAndConditions"', () => { 111 | render(); 112 | expect(screen.getByRole('switch')).toHaveAttribute( 113 | 'name', 114 | 'acceptTermsAndConditions' 115 | ); 116 | }); 117 | 118 | test('responds to a change in validity state when required=true', async () => { 119 | const onInvalid = jest.fn(); 120 | 121 | render(); 122 | 123 | expect(screen.getByRole('switch')).toBeRequired(); 124 | expect(screen.getByRole('switch')).toBeInvalid(); 125 | expect(onInvalid).toHaveBeenCalled(); 126 | 127 | await userEvent.click(screen.getByTestId('heart-switch')); 128 | 129 | expect(screen.getByRole('switch')).toBeValid(); 130 | }); 131 | 132 | test('renders with a value attribute when value="true"', () => { 133 | render(); 134 | expect(screen.getByRole('switch').value).toBe('true'); 135 | }); 136 | 137 | test('renders with an id attribute when id="heart-switch"', () => { 138 | render(); 139 | expect(screen.getByRole('switch')).toHaveAttribute('id', 'heart-switch'); 140 | }); 141 | 142 | test('renders with a title attribute when title="Accept Terms and Conditions"', () => { 143 | render(); 144 | expect(screen.getByTestId('heart-switch')).toHaveAttribute( 145 | 'title', 146 | 'Accept Terms and Conditions' 147 | ); 148 | }); 149 | 150 | test('renders in an unfocusable state when tabIndex=-1', async () => { 151 | const onFocus = jest.fn(); 152 | 153 | render(); 154 | 155 | expect(screen.getByRole('switch')).toHaveAttribute('tabIndex', '-1'); 156 | expect(screen.getByRole('switch')).not.toHaveFocus(); 157 | expect(onFocus).not.toHaveBeenCalled(); 158 | 159 | await userEvent.tab(); 160 | 161 | expect(screen.getByRole('switch')).not.toHaveFocus(); 162 | expect(onFocus).not.toHaveBeenCalled(); 163 | }); 164 | 165 | test('renders in a disabled but focusable state when aria-disabled="true"', async () => { 166 | const onBlur = jest.fn(); 167 | const onFocus = jest.fn(); 168 | 169 | render( 170 | 171 | ); 172 | 173 | expect(screen.getByTestId('heart-switch')).toHaveAttribute( 174 | 'data-disabled', 175 | 'true' 176 | ); 177 | expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true'); 178 | expect(screen.getByRole('switch')).not.toBeChecked(); 179 | expect(screen.getByRole('switch')).not.toHaveFocus(); 180 | expect(onBlur).not.toHaveBeenCalled(); 181 | expect(onFocus).not.toHaveBeenCalled(); 182 | 183 | await userEvent.tab(); 184 | 185 | expect(screen.getByRole('switch')).toHaveFocus(); 186 | expect(onBlur).not.toHaveBeenCalled(); 187 | expect(onFocus).toHaveBeenCalled(); 188 | 189 | await userEvent.keyboard('{space/}'); 190 | 191 | expect(screen.getByRole('switch')).not.toBeChecked(); 192 | 193 | await userEvent.tab(); 194 | 195 | expect(screen.getByRole('switch')).not.toHaveFocus(); 196 | expect(onBlur).toHaveBeenCalled(); 197 | 198 | await userEvent.click(screen.getByTestId('heart-switch')); 199 | 200 | expect(screen.getByRole('switch')).not.toBeChecked(); 201 | }); 202 | 203 | test('renders with an aria-label attribute when aria-label="Accept Terms and Conditions"', () => { 204 | render(); 205 | expect(screen.getByRole('switch')).toHaveAttribute( 206 | 'aria-label', 207 | 'Accept Terms and Conditions' 208 | ); 209 | }); 210 | 211 | test('renders with an aria-describedby attribute when aria-describedby="termsAndConditionsDescription"', () => { 212 | render(); 213 | expect(screen.getByRole('switch')).toHaveAttribute( 214 | 'aria-describedby', 215 | 'termsAndConditionsDescription' 216 | ); 217 | }); 218 | 219 | test('renders with an aria-labelledby attribute when aria-labelledby="termsAndConditions"', () => { 220 | render(); 221 | expect(screen.getByRole('switch')).toHaveAttribute( 222 | 'aria-labelledby', 223 | 'termsAndConditions' 224 | ); 225 | }); 226 | 227 | test('responds to a change in focused state when tabbing with the keyboard', async () => { 228 | const onBlur = jest.fn(); 229 | const onFocus = jest.fn(); 230 | 231 | render(); 232 | 233 | expect(screen.getByRole('switch')).not.toHaveFocus(); 234 | expect(onBlur).not.toHaveBeenCalled(); 235 | expect(onFocus).not.toHaveBeenCalled(); 236 | 237 | await userEvent.tab(); 238 | 239 | expect(screen.getByRole('switch')).toHaveFocus(); 240 | expect(onBlur).not.toHaveBeenCalled(); 241 | expect(onFocus).toHaveBeenCalled(); 242 | 243 | await userEvent.tab(); 244 | 245 | expect(screen.getByRole('switch')).not.toHaveFocus(); 246 | expect(onBlur).toHaveBeenCalled(); 247 | }); 248 | 249 | test('integrates with a form', async () => { 250 | render( 251 |
252 | 253 | 254 | 255 | ); 256 | 257 | expect(screen.getByTestId('complianceForm')).toHaveFormValues({ 258 | fullName: 'Anatoliy Gatt', 259 | acceptTermsAndConditions: false, 260 | }); 261 | 262 | await userEvent.click(screen.getByTestId('heart-switch')); 263 | 264 | expect(screen.getByTestId('complianceForm')).toHaveFormValues({ 265 | fullName: 'Anatoliy Gatt', 266 | acceptTermsAndConditions: true, 267 | }); 268 | }); 269 | 270 | test('complies with the web accessibility standards', async () => { 271 | const { container } = render( 272 | 273 | ); 274 | const results = await axe(container); 275 | expect(results).toHaveNoViolations(); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/HeartSwitch.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`HeartSwitch renders with the default size and colors 1`] = ` 4 | @keyframes animation-0 { 5 | 0% { 6 | -webkit-transform: rotate(30deg); 7 | -moz-transform: rotate(30deg); 8 | -ms-transform: rotate(30deg); 9 | transform: rotate(30deg); 10 | } 11 | 12 | 25% { 13 | -webkit-transform: rotate(30deg) translateX(4.5px) scaleX(1.1); 14 | -moz-transform: rotate(30deg) translateX(4.5px) scaleX(1.1); 15 | -ms-transform: rotate(30deg) translateX(4.5px) scaleX(1.1); 16 | transform: rotate(30deg) translateX(4.5px) scaleX(1.1); 17 | } 18 | 19 | 50% { 20 | -webkit-transform: rotate(30deg) translateX(9px); 21 | -moz-transform: rotate(30deg) translateX(9px); 22 | -ms-transform: rotate(30deg) translateX(9px); 23 | transform: rotate(30deg) translateX(9px); 24 | } 25 | 26 | 100% { 27 | -webkit-transform: rotate(-30deg) translateX(13.5px) translateY(8px); 28 | -moz-transform: rotate(-30deg) translateX(13.5px) translateY(8px); 29 | -ms-transform: rotate(-30deg) translateX(13.5px) translateY(8px); 30 | transform: rotate(-30deg) translateX(13.5px) translateY(8px); 31 | } 32 | } 33 | 34 | @keyframes animation-1 { 35 | 0% { 36 | -webkit-transform: rotate(-30deg) translateX(13.5px) translateY(8px); 37 | -moz-transform: rotate(-30deg) translateX(13.5px) translateY(8px); 38 | -ms-transform: rotate(-30deg) translateX(13.5px) translateY(8px); 39 | transform: rotate(-30deg) translateX(13.5px) translateY(8px); 40 | } 41 | 42 | 50% { 43 | -webkit-transform: rotate(30deg) translateX(9px); 44 | -moz-transform: rotate(30deg) translateX(9px); 45 | -ms-transform: rotate(30deg) translateX(9px); 46 | transform: rotate(30deg) translateX(9px); 47 | } 48 | 49 | 75% { 50 | -webkit-transform: rotate(30deg) translateX(4.5px) scaleX(1.1); 51 | -moz-transform: rotate(30deg) translateX(4.5px) scaleX(1.1); 52 | -ms-transform: rotate(30deg) translateX(4.5px) scaleX(1.1); 53 | transform: rotate(30deg) translateX(4.5px) scaleX(1.1); 54 | } 55 | 56 | 100% { 57 | -webkit-transform: rotate(30deg); 58 | -moz-transform: rotate(30deg); 59 | -ms-transform: rotate(30deg); 60 | transform: rotate(30deg); 61 | } 62 | } 63 | 64 | .emotion-0 { 65 | position: relative; 66 | display: block; 67 | cursor: pointer; 68 | -webkit-tap-highlight-color: transparent; 69 | } 70 | 71 | .emotion-0 input { 72 | position: absolute; 73 | top: 1px; 74 | left: 1px; 75 | -webkit-transition: border-width 50ms,background-color 350ms; 76 | transition: border-width 50ms,background-color 350ms; 77 | -webkit-appearance: none; 78 | -moz-appearance: none; 79 | -ms-appearance: none; 80 | appearance: none; 81 | margin: 0; 82 | box-shadow: 0 0 2px 1px rgb(23 23 23 / 0.25); 83 | outline: none; 84 | border: 0 solid rgb(59 130 246 / 0.5); 85 | border-radius: 50%; 86 | width: 18px; 87 | height: 18px; 88 | background-color: #ffffff; 89 | pointer-events: none; 90 | } 91 | 92 | .emotion-0 input+svg { 93 | display: block; 94 | -webkit-transition: fill 350ms,stroke 350ms; 95 | transition: fill 350ms,stroke 350ms; 96 | width: 36px; 97 | height: 25px; 98 | fill: #ffffff; 99 | stroke: #d1d1d1; 100 | stroke-linejoin: round; 101 | } 102 | 103 | .emotion-0 input:checked { 104 | -webkit-animation-name: animation-0; 105 | animation-name: animation-0; 106 | -webkit-animation-timing-function: linear; 107 | animation-timing-function: linear; 108 | -webkit-animation-fill-mode: forwards; 109 | animation-fill-mode: forwards; 110 | background-color: #ffffff; 111 | } 112 | 113 | .emotion-0 input:checked+svg { 114 | fill: #ff708f; 115 | stroke: #ff4e74; 116 | } 117 | 118 | .emotion-0 input:not(:checked) { 119 | -webkit-animation-name: animation-1; 120 | animation-name: animation-1; 121 | -webkit-animation-timing-function: linear; 122 | animation-timing-function: linear; 123 | -webkit-animation-fill-mode: backwards; 124 | animation-fill-mode: backwards; 125 | } 126 | 127 | .emotion-0 input:focus { 128 | border-width: 1px; 129 | } 130 | 131 | .emotion-0 input:focus:not(:focus-visible) { 132 | border-width: 0; 133 | } 134 | 135 | .emotion-0 input:focus-visible { 136 | border-width: 1px; 137 | } 138 | 139 | .emotion-0 input:invalid { 140 | background-color: #ffffff; 141 | } 142 | 143 | .emotion-0 input:invalid+svg { 144 | fill: #ffffff; 145 | stroke: #d1d1d1; 146 | } 147 | 148 | .emotion-0[data-disabled='true'] { 149 | cursor: default; 150 | } 151 | 152 | .emotion-0[data-disabled='true'] input { 153 | background-color: #ffffff; 154 | } 155 | 156 | .emotion-0[data-disabled='true'] input+svg { 157 | fill: #f2f2f2; 158 | stroke: #d1d1d1; 159 | } 160 | 161 | 181 | `; 182 | 183 | exports[`HeartSwitch renders with the size="lg" and custom colors 1`] = ` 184 | @keyframes animation-0 { 185 | 0% { 186 | -webkit-transform: rotate(30deg); 187 | -moz-transform: rotate(30deg); 188 | -ms-transform: rotate(30deg); 189 | transform: rotate(30deg); 190 | } 191 | 192 | 25% { 193 | -webkit-transform: rotate(30deg) translateX(9px) scaleX(1.1); 194 | -moz-transform: rotate(30deg) translateX(9px) scaleX(1.1); 195 | -ms-transform: rotate(30deg) translateX(9px) scaleX(1.1); 196 | transform: rotate(30deg) translateX(9px) scaleX(1.1); 197 | } 198 | 199 | 50% { 200 | -webkit-transform: rotate(30deg) translateX(18px); 201 | -moz-transform: rotate(30deg) translateX(18px); 202 | -ms-transform: rotate(30deg) translateX(18px); 203 | transform: rotate(30deg) translateX(18px); 204 | } 205 | 206 | 100% { 207 | -webkit-transform: rotate(-30deg) translateX(27px) translateY(16px); 208 | -moz-transform: rotate(-30deg) translateX(27px) translateY(16px); 209 | -ms-transform: rotate(-30deg) translateX(27px) translateY(16px); 210 | transform: rotate(-30deg) translateX(27px) translateY(16px); 211 | } 212 | } 213 | 214 | @keyframes animation-1 { 215 | 0% { 216 | -webkit-transform: rotate(-30deg) translateX(27px) translateY(16px); 217 | -moz-transform: rotate(-30deg) translateX(27px) translateY(16px); 218 | -ms-transform: rotate(-30deg) translateX(27px) translateY(16px); 219 | transform: rotate(-30deg) translateX(27px) translateY(16px); 220 | } 221 | 222 | 50% { 223 | -webkit-transform: rotate(30deg) translateX(18px); 224 | -moz-transform: rotate(30deg) translateX(18px); 225 | -ms-transform: rotate(30deg) translateX(18px); 226 | transform: rotate(30deg) translateX(18px); 227 | } 228 | 229 | 75% { 230 | -webkit-transform: rotate(30deg) translateX(9px) scaleX(1.1); 231 | -moz-transform: rotate(30deg) translateX(9px) scaleX(1.1); 232 | -ms-transform: rotate(30deg) translateX(9px) scaleX(1.1); 233 | transform: rotate(30deg) translateX(9px) scaleX(1.1); 234 | } 235 | 236 | 100% { 237 | -webkit-transform: rotate(30deg); 238 | -moz-transform: rotate(30deg); 239 | -ms-transform: rotate(30deg); 240 | transform: rotate(30deg); 241 | } 242 | } 243 | 244 | .emotion-0 { 245 | position: relative; 246 | display: block; 247 | cursor: pointer; 248 | -webkit-tap-highlight-color: transparent; 249 | } 250 | 251 | .emotion-0 input { 252 | position: absolute; 253 | top: 2px; 254 | left: 2px; 255 | -webkit-transition: border-width 50ms,background-color 350ms; 256 | transition: border-width 50ms,background-color 350ms; 257 | -webkit-appearance: none; 258 | -moz-appearance: none; 259 | -ms-appearance: none; 260 | appearance: none; 261 | margin: 0; 262 | box-shadow: 0 0 4px 2px rgb(23 23 23 / 0.25); 263 | outline: none; 264 | border: 0 solid rgb(59 130 246 / 0.5); 265 | border-radius: 50%; 266 | width: 36px; 267 | height: 36px; 268 | background-color: #ecfeff; 269 | pointer-events: none; 270 | } 271 | 272 | .emotion-0 input+svg { 273 | display: block; 274 | -webkit-transition: fill 350ms,stroke 350ms; 275 | transition: fill 350ms,stroke 350ms; 276 | width: 72px; 277 | height: 50px; 278 | fill: #cffafe; 279 | stroke: #22d3ee; 280 | stroke-linejoin: round; 281 | } 282 | 283 | .emotion-0 input:checked { 284 | -webkit-animation-name: animation-0; 285 | animation-name: animation-0; 286 | -webkit-animation-timing-function: linear; 287 | animation-timing-function: linear; 288 | -webkit-animation-fill-mode: forwards; 289 | animation-fill-mode: forwards; 290 | background-color: #ecfeff; 291 | } 292 | 293 | .emotion-0 input:checked+svg { 294 | fill: #06b6d4; 295 | stroke: #0891b2; 296 | } 297 | 298 | .emotion-0 input:not(:checked) { 299 | -webkit-animation-name: animation-1; 300 | animation-name: animation-1; 301 | -webkit-animation-timing-function: linear; 302 | animation-timing-function: linear; 303 | -webkit-animation-fill-mode: backwards; 304 | animation-fill-mode: backwards; 305 | } 306 | 307 | .emotion-0 input:focus { 308 | border-width: 2px; 309 | } 310 | 311 | .emotion-0 input:focus:not(:focus-visible) { 312 | border-width: 0; 313 | } 314 | 315 | .emotion-0 input:focus-visible { 316 | border-width: 2px; 317 | } 318 | 319 | .emotion-0 input:invalid { 320 | background-color: #ffffff; 321 | } 322 | 323 | .emotion-0 input:invalid+svg { 324 | fill: #ffffff; 325 | stroke: #d1d1d1; 326 | } 327 | 328 | .emotion-0[data-disabled='true'] { 329 | cursor: default; 330 | } 331 | 332 | .emotion-0[data-disabled='true'] input { 333 | background-color: #ffffff; 334 | } 335 | 336 | .emotion-0[data-disabled='true'] input+svg { 337 | fill: #f2f2f2; 338 | stroke: #d1d1d1; 339 | } 340 | 341 | 361 | `; 362 | 363 | exports[`HeartSwitch renders with the size="md" and default colors 1`] = ` 364 | @keyframes animation-0 { 365 | 0% { 366 | -webkit-transform: rotate(30deg); 367 | -moz-transform: rotate(30deg); 368 | -ms-transform: rotate(30deg); 369 | transform: rotate(30deg); 370 | } 371 | 372 | 25% { 373 | -webkit-transform: rotate(30deg) translateX(6.75px) scaleX(1.1); 374 | -moz-transform: rotate(30deg) translateX(6.75px) scaleX(1.1); 375 | -ms-transform: rotate(30deg) translateX(6.75px) scaleX(1.1); 376 | transform: rotate(30deg) translateX(6.75px) scaleX(1.1); 377 | } 378 | 379 | 50% { 380 | -webkit-transform: rotate(30deg) translateX(13.5px); 381 | -moz-transform: rotate(30deg) translateX(13.5px); 382 | -ms-transform: rotate(30deg) translateX(13.5px); 383 | transform: rotate(30deg) translateX(13.5px); 384 | } 385 | 386 | 100% { 387 | -webkit-transform: rotate(-30deg) translateX(20.25px) translateY(12px); 388 | -moz-transform: rotate(-30deg) translateX(20.25px) translateY(12px); 389 | -ms-transform: rotate(-30deg) translateX(20.25px) translateY(12px); 390 | transform: rotate(-30deg) translateX(20.25px) translateY(12px); 391 | } 392 | } 393 | 394 | @keyframes animation-1 { 395 | 0% { 396 | -webkit-transform: rotate(-30deg) translateX(20.25px) translateY(12px); 397 | -moz-transform: rotate(-30deg) translateX(20.25px) translateY(12px); 398 | -ms-transform: rotate(-30deg) translateX(20.25px) translateY(12px); 399 | transform: rotate(-30deg) translateX(20.25px) translateY(12px); 400 | } 401 | 402 | 50% { 403 | -webkit-transform: rotate(30deg) translateX(13.5px); 404 | -moz-transform: rotate(30deg) translateX(13.5px); 405 | -ms-transform: rotate(30deg) translateX(13.5px); 406 | transform: rotate(30deg) translateX(13.5px); 407 | } 408 | 409 | 75% { 410 | -webkit-transform: rotate(30deg) translateX(6.75px) scaleX(1.1); 411 | -moz-transform: rotate(30deg) translateX(6.75px) scaleX(1.1); 412 | -ms-transform: rotate(30deg) translateX(6.75px) scaleX(1.1); 413 | transform: rotate(30deg) translateX(6.75px) scaleX(1.1); 414 | } 415 | 416 | 100% { 417 | -webkit-transform: rotate(30deg); 418 | -moz-transform: rotate(30deg); 419 | -ms-transform: rotate(30deg); 420 | transform: rotate(30deg); 421 | } 422 | } 423 | 424 | .emotion-0 { 425 | position: relative; 426 | display: block; 427 | cursor: pointer; 428 | -webkit-tap-highlight-color: transparent; 429 | } 430 | 431 | .emotion-0 input { 432 | position: absolute; 433 | top: 1.5px; 434 | left: 1.5px; 435 | -webkit-transition: border-width 50ms,background-color 350ms; 436 | transition: border-width 50ms,background-color 350ms; 437 | -webkit-appearance: none; 438 | -moz-appearance: none; 439 | -ms-appearance: none; 440 | appearance: none; 441 | margin: 0; 442 | box-shadow: 0 0 3px 1.5px rgb(23 23 23 / 0.25); 443 | outline: none; 444 | border: 0 solid rgb(59 130 246 / 0.5); 445 | border-radius: 50%; 446 | width: 27px; 447 | height: 27px; 448 | background-color: #ffffff; 449 | pointer-events: none; 450 | } 451 | 452 | .emotion-0 input+svg { 453 | display: block; 454 | -webkit-transition: fill 350ms,stroke 350ms; 455 | transition: fill 350ms,stroke 350ms; 456 | width: 54px; 457 | height: 37.5px; 458 | fill: #ffffff; 459 | stroke: #d1d1d1; 460 | stroke-linejoin: round; 461 | } 462 | 463 | .emotion-0 input:checked { 464 | -webkit-animation-name: animation-0; 465 | animation-name: animation-0; 466 | -webkit-animation-timing-function: linear; 467 | animation-timing-function: linear; 468 | -webkit-animation-fill-mode: forwards; 469 | animation-fill-mode: forwards; 470 | background-color: #ffffff; 471 | } 472 | 473 | .emotion-0 input:checked+svg { 474 | fill: #ff708f; 475 | stroke: #ff4e74; 476 | } 477 | 478 | .emotion-0 input:not(:checked) { 479 | -webkit-animation-name: animation-1; 480 | animation-name: animation-1; 481 | -webkit-animation-timing-function: linear; 482 | animation-timing-function: linear; 483 | -webkit-animation-fill-mode: backwards; 484 | animation-fill-mode: backwards; 485 | } 486 | 487 | .emotion-0 input:focus { 488 | border-width: 1.5px; 489 | } 490 | 491 | .emotion-0 input:focus:not(:focus-visible) { 492 | border-width: 0; 493 | } 494 | 495 | .emotion-0 input:focus-visible { 496 | border-width: 1.5px; 497 | } 498 | 499 | .emotion-0 input:invalid { 500 | background-color: #ffffff; 501 | } 502 | 503 | .emotion-0 input:invalid+svg { 504 | fill: #ffffff; 505 | stroke: #d1d1d1; 506 | } 507 | 508 | .emotion-0[data-disabled='true'] { 509 | cursor: default; 510 | } 511 | 512 | .emotion-0[data-disabled='true'] input { 513 | background-color: #ffffff; 514 | } 515 | 516 | .emotion-0[data-disabled='true'] input+svg { 517 | fill: #f2f2f2; 518 | stroke: #d1d1d1; 519 | } 520 | 521 | 541 | `; 542 | --------------------------------------------------------------------------------