From cb4240e6a0d2d12cc5de5cd47d796229e9a1029f Mon Sep 17 00:00:00 2001 From: cheton Date: Thu, 31 Aug 2023 10:38:12 +0800 Subject: [PATCH] feat: implement `:focus-visible` for the Switch component with targeted focus style for non-pointer devices --- .../pages/components/switch/basic.js | 12 ++ .../pages/components/switch/colors.js | 17 ++ .../components/switch/faq-input-element.js | 27 +++ .../pages/components/switch/flex-layout.js | 24 +++ .../index.page.mdx} | 94 +-------- .../pages/components/switch/sizes.js | 20 ++ .../pages/components/switch/states.js | 27 +++ packages/react/src/switch/SwitchControlBox.js | 163 +++++++++++---- .../__snapshots__/Switch.test.js.snap | 189 +++++++++--------- packages/react/src/switch/styles.js | 65 +----- 10 files changed, 364 insertions(+), 274 deletions(-) create mode 100644 packages/react-docs/pages/components/switch/basic.js create mode 100644 packages/react-docs/pages/components/switch/colors.js create mode 100644 packages/react-docs/pages/components/switch/faq-input-element.js create mode 100644 packages/react-docs/pages/components/switch/flex-layout.js rename packages/react-docs/pages/components/{switch.page.mdx => switch/index.page.mdx} (71%) create mode 100644 packages/react-docs/pages/components/switch/sizes.js create mode 100644 packages/react-docs/pages/components/switch/states.js diff --git a/packages/react-docs/pages/components/switch/basic.js b/packages/react-docs/pages/components/switch/basic.js new file mode 100644 index 0000000000..d59c5fe4ff --- /dev/null +++ b/packages/react-docs/pages/components/switch/basic.js @@ -0,0 +1,12 @@ +import { Switch } from '@tonic-ui/react'; +import React from 'react'; + +const App = () => { + return ( + + Label + + ); +}; + +export default App; diff --git a/packages/react-docs/pages/components/switch/colors.js b/packages/react-docs/pages/components/switch/colors.js new file mode 100644 index 0000000000..a088ec5112 --- /dev/null +++ b/packages/react-docs/pages/components/switch/colors.js @@ -0,0 +1,17 @@ +import { Flex, Switch } from '@tonic-ui/react'; +import React from 'react'; + +const App = () => { + return ( + + + Label + + + Label + + + ); +}; + +export default App; diff --git a/packages/react-docs/pages/components/switch/faq-input-element.js b/packages/react-docs/pages/components/switch/faq-input-element.js new file mode 100644 index 0000000000..540c89d4bc --- /dev/null +++ b/packages/react-docs/pages/components/switch/faq-input-element.js @@ -0,0 +1,27 @@ +import { Button, Flex, Switch } from '@tonic-ui/react'; +import React, { useRef } from 'react'; + +const App = () => { + const inputRef = useRef(); + + const handleClick = () => { + const inputEl = inputRef?.current; + if (inputEl) { + inputEl.focus(); + window.alert('The switch toggle is ' + (inputEl.checked ? 'on' : 'off')); + } + }; + + return ( + + + Label + + + + ); +}; + +export default App; diff --git a/packages/react-docs/pages/components/switch/flex-layout.js b/packages/react-docs/pages/components/switch/flex-layout.js new file mode 100644 index 0000000000..a37dc03f57 --- /dev/null +++ b/packages/react-docs/pages/components/switch/flex-layout.js @@ -0,0 +1,24 @@ +import { Box, Flex, Switch, Text } from '@tonic-ui/react'; +import React from 'react'; + +const App = () => { + return ( + + + + Label + Helper text + + + ); +}; + +export default App; diff --git a/packages/react-docs/pages/components/switch.page.mdx b/packages/react-docs/pages/components/switch/index.page.mdx similarity index 71% rename from packages/react-docs/pages/components/switch.page.mdx rename to packages/react-docs/pages/components/switch/index.page.mdx index 1e6efca46f..5418c624a3 100644 --- a/packages/react-docs/pages/components/switch.page.mdx +++ b/packages/react-docs/pages/components/switch/index.page.mdx @@ -10,87 +10,29 @@ import { Switch } from '@tonic-ui/react'; ## Usage -```jsx - - Label - -``` +### Basic + +{render('./basic')} You can use a flex container to align a switch with other components. This allows you to easily control the positioning and spacing of all elements within the container. -```jsx - - - - Label - Helper text - - -``` +{render('./flex-layout')} ### Colors Use the `variantColor` prop to change the color scheme of a radio button. `variantColor` can be any color key with key `50` (hover) or `60` (checked) that exist in `theme.colors`. -```jsx - - - Label - - - Label - - -``` +{render('./colors')} ### Sizes Use the `size` prop to change the size of the switch. You can set the value to `sm`, `md`, or `lg`. -```jsx - - - Label - - - Label - - - Label - - -``` +{render('./sizes')} ### States -```jsx - - - - Label - - - Label - - - - - Label - - - Label - - - -``` +{render('./states')} ## Accessibility @@ -117,27 +59,7 @@ Once you have obtained the reference to the input element, you can access its pr Here's an example of how you can utilize the `inputRef` prop to access the input element and perform actions: -```jsx -function Example() { - const inputRef = React.useRef(); - - const handleClick = () => { - inputRef.current.focus(); - console.log(inputRef.current.checked); // => true - }; - - return ( - - - Label - - - - ); -} -``` +{render('./faq-input-element')} ## Props diff --git a/packages/react-docs/pages/components/switch/sizes.js b/packages/react-docs/pages/components/switch/sizes.js new file mode 100644 index 0000000000..66ec62285d --- /dev/null +++ b/packages/react-docs/pages/components/switch/sizes.js @@ -0,0 +1,20 @@ +import { Flex, Switch } from '@tonic-ui/react'; +import React from 'react'; + +const App = () => { + return ( + + + Label + + + Label + + + Label + + + ); +}; + +export default App; diff --git a/packages/react-docs/pages/components/switch/states.js b/packages/react-docs/pages/components/switch/states.js new file mode 100644 index 0000000000..809c638488 --- /dev/null +++ b/packages/react-docs/pages/components/switch/states.js @@ -0,0 +1,27 @@ +import { Flex, Stack, Switch } from '@tonic-ui/react'; +import React from 'react'; + +const App = () => { + return ( + + + + Label + + + Label + + + + + Label + + + Label + + + + ); +}; + +export default App; diff --git a/packages/react/src/switch/SwitchControlBox.js b/packages/react/src/switch/SwitchControlBox.js index 2bc767e9b4..72e3e01a00 100644 --- a/packages/react/src/switch/SwitchControlBox.js +++ b/packages/react/src/switch/SwitchControlBox.js @@ -1,6 +1,7 @@ -import { createTransitionStyle } from '@tonic-ui/utils'; +import { ariaAttr, createTransitionStyle } from '@tonic-ui/utils'; +import { ensureArray, ensureString } from 'ensure-type'; import React, { forwardRef } from 'react'; -import { Box, ControlBox } from '../box'; +import { Box } from '../box'; import { useColorMode } from '../color-mode'; import { defaultSize, defaultVariantColor } from './constants'; import { useSwitchControlBoxStyle } from './styles'; @@ -9,6 +10,7 @@ const SwitchControlBox = forwardRef(( { size = defaultSize, variantColor = defaultVariantColor, + sx: sxProp, ...rest }, ref, @@ -29,29 +31,122 @@ const SwitchControlBox = forwardRef(( md: 9, lg: 12, }[size]; - const trackHaloWidth = 2; - const trackBorderWidth = 1; - const trackHaloX = 0; - const trackHaloY = 0; - const trackBorderX = trackHaloX + trackHaloWidth; - const trackBorderY = trackHaloY + trackHaloWidth; - const trackX = trackBorderX + trackBorderWidth; - const trackY = trackBorderY + trackBorderWidth; - const viewBoxWidth = width + (trackHaloWidth + trackBorderWidth) * 2; - const viewBoxHeight = height + (trackHaloWidth + trackBorderWidth) * 2; - const trackFillColor = { + const switchOuterBorderWidth = 2; + const switchInnerBorderWidth = 1; + const switchOuterBorderX = 0; + const switchOuterBorderY = 0; + const switchInnerBorderX = switchOuterBorderX + switchOuterBorderWidth; + const switchInnerBorderY = switchOuterBorderY + switchOuterBorderWidth; + const switchTrackX = switchInnerBorderX + switchInnerBorderWidth; + const switchTrackY = switchInnerBorderY + switchInnerBorderWidth; + const viewBoxWidth = width + (switchOuterBorderWidth + switchInnerBorderWidth) * 2; + const viewBoxHeight = height + (switchOuterBorderWidth + switchInnerBorderWidth) * 2; + + // switch-outer-border + const switchOuterBorderColor = { + dark: `${variantColor}:60`, + light: `${variantColor}:60`, + }[colorMode]; + + // switch-inner-border + const switchInnerBorderColor = { + dark: 'black', + light: 'white', + }[colorMode]; + + // switch-track + const switchTrackColor = { dark: 'gray:60', light: 'gray:30', }[colorMode]; - const styleProps = useSwitchControlBoxStyle({ - color: variantColor, - width, - height, - }); + const switchTrackHoverColor = { + dark: 'gray:50', + light: 'gray:20', + }[colorMode]; + const switchTrackCheckedColor = { + dark: `${variantColor}:60`, + light: `${variantColor}:60`, + }[colorMode]; + const switchTrackCheckedHoverColor = { + dark: `${variantColor}:50`, + light: `${variantColor}:50`, + }[colorMode]; + + // switch-thumb + const switchThumbColor = { + dark: 'white', + light: 'white', + }[colorMode]; + + const inputType = 'checkbox'; + const getSwitchControlBoxSelector = (pseudos) => { + return `input[type="${inputType}"]` + ensureString(pseudos) + ' + &'; + }; + const getSwitchOuterBorderSelector = (pseudos) => { + return getSwitchControlBoxSelector(pseudos) + '> [data-switch] > [data-switch-outer-border]'; + }; + const getSwitchInnerBorderSelector = (pseudos) => { + return getSwitchControlBoxSelector(pseudos) + '> [data-switch] > [data-switch-inner-border]'; + }; + const getSwitchTrackSelector = (pseudos) => { + return getSwitchControlBoxSelector(pseudos) + '> [data-switch] > [data-switch-track]'; + }; + const getSwitchThumbSelector = (pseudos) => { + return getSwitchControlBoxSelector(pseudos) + '> [data-switch] > [data-switch-thumb]'; + }; + const sx = { + width: viewBoxWidth, + height: viewBoxHeight, + + [getSwitchControlBoxSelector(':disabled')]: { + opacity: 0.28, + }, + + // switch-outer-border + [getSwitchOuterBorderSelector()]: { + fill: 'none', + }, + [getSwitchOuterBorderSelector(':focus-visible')]: { + fill: switchOuterBorderColor, + }, + + // switch-inner-border + [getSwitchInnerBorderSelector()]: { + fill: 'none', + }, + [getSwitchInnerBorderSelector(':focus-visible')]: { + fill: switchInnerBorderColor, + }, + + // switch-track + [getSwitchTrackSelector()]: { + fill: switchTrackColor, + }, + [getSwitchTrackSelector(':hover:not(:disabled)')]: { + fill: switchTrackHoverColor, + }, + [getSwitchTrackSelector(':checked:not(:disabled)')]: { + fill: switchTrackCheckedColor, + }, + [getSwitchTrackSelector(':checked:hover:not(:disabled)')]: { + fill: switchTrackCheckedHoverColor, + }, + + // switch-thumb + [getSwitchThumbSelector()]: { + fill: switchThumbColor, + }, + [getSwitchThumbSelector(':checked')]: { + transform: `translateX(${height}px)`, + }, + }; + const styleProps = useSwitchControlBoxStyle(); return ( - @@ -64,35 +159,32 @@ const SwitchControlBox = forwardRef(( > - + ); }); diff --git a/packages/react/src/switch/__tests__/__snapshots__/Switch.test.js.snap b/packages/react/src/switch/__tests__/__snapshots__/Switch.test.js.snap index c56051bdb8..e7550509cc 100644 --- a/packages/react/src/switch/__tests__/__snapshots__/Switch.test.js.snap +++ b/packages/react/src/switch/__tests__/__snapshots__/Switch.test.js.snap @@ -39,8 +39,6 @@ exports[`Switch should render correctly 1`] = ` -ms-flex-pack: center; -webkit-justify-content: center; justify-content: center; - -webkit-transition: all 120ms; - transition: all 120ms; -webkit-flex-shrink: 0; -ms-flex-negative: 0; flex-shrink: 0; @@ -52,49 +50,53 @@ exports[`Switch should render correctly 1`] = ` height: 22px; } -input[type=checkbox]:focus+.emotion-4 [data-switch] [data-switch-track-halo] { +input[type="checkbox"]:disabled+.emotion-4 { + opacity: 0.28; +} + +input[type="checkbox"]+.emotion-4>[data-switch]>[data-switch-outer-border] { + fill: none; +} + +input[type="checkbox"]:focus-visible+.emotion-4>[data-switch]>[data-switch-outer-border] { fill: var(--tonic-colors-blue-60); } -input[type=checkbox]:focus+.emotion-4 [data-switch] [data-switch-track-border] { +input[type="checkbox"]+.emotion-4>[data-switch]>[data-switch-inner-border] { + fill: none; +} + +input[type="checkbox"]:focus-visible+.emotion-4>[data-switch]>[data-switch-inner-border] { fill: black; } -input[type=checkbox]:hover:not(:disabled):not(:checked):not(:focus)+.emotion-4 [data-switch] [data-switch-track] { - fill: var(--tonic-colors-gray-50); +input[type="checkbox"]+.emotion-4>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-gray-60); } -input[type=checkbox]:disabled+.emotion-4 { - opacity: 0.28; +input[type="checkbox"]:hover:not(:disabled)+.emotion-4>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-gray-50); } -input[type=checkbox]:checked:hover:not(:disabled):not(:focus)+.emotion-4 [data-switch] [data-switch-track] { - fill: var(--tonic-colors-blue-50); +input[type="checkbox"]:checked:not(:disabled)+.emotion-4>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-blue-60); } -.emotion-4>* { - opacity: 1; +input[type="checkbox"]:checked:hover:not(:disabled)+.emotion-4>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-blue-50); } -input[type=checkbox]:checked+.emotion-4 [data-switch] [data-switch-track] { - fill: var(--tonic-colors-blue-60); +input[type="checkbox"]+.emotion-4>[data-switch]>[data-switch-thumb] { + fill: white; } -input[type=checkbox]:checked+.emotion-4 [data-switch] [data-switch-thumb] { +input[type="checkbox"]:checked+.emotion-4>[data-switch]>[data-switch-thumb] { -webkit-transform: translateX(16px); -moz-transform: translateX(16px); -ms-transform: translateX(16px); transform: translateX(16px); } -input[type=checkbox]:checked+.emotion-4>* { - opacity: 1; -} - -input[type=checkbox][data-indeterminate]+.emotion-4>* { - opacity: 1; -} - .emotion-6 { width: 100%; height: 100%; @@ -103,24 +105,20 @@ input[type=checkbox][data-indeterminate]+.emotion-4>* { .emotion-8 { width: 38px; height: 22px; - fill: none; } .emotion-10 { width: 34px; height: 18px; - fill: none; } .emotion-12 { width: 32px; height: 16px; - fill: var(--tonic-colors-gray-60); pointer-events: all; } .emotion-14 { - fill: var(--tonic-colors-white-emphasis); -webkit-transform: translateX(0); -moz-transform: translateX(0); -ms-transform: translateX(0); @@ -152,8 +150,6 @@ input[type=checkbox][data-indeterminate]+.emotion-4>* { -ms-flex-pack: center; -webkit-justify-content: center; justify-content: center; - -webkit-transition: all 120ms; - transition: all 120ms; -webkit-flex-shrink: 0; -ms-flex-negative: 0; flex-shrink: 0; @@ -165,65 +161,66 @@ input[type=checkbox][data-indeterminate]+.emotion-4>* { height: 30px; } -input[type=checkbox]:focus+.emotion-22 [data-switch] [data-switch-track-halo] { +input[type="checkbox"]:disabled+.emotion-22 { + opacity: 0.28; +} + +input[type="checkbox"]+.emotion-22>[data-switch]>[data-switch-outer-border] { + fill: none; +} + +input[type="checkbox"]:focus-visible+.emotion-22>[data-switch]>[data-switch-outer-border] { fill: var(--tonic-colors-blue-60); } -input[type=checkbox]:focus+.emotion-22 [data-switch] [data-switch-track-border] { +input[type="checkbox"]+.emotion-22>[data-switch]>[data-switch-inner-border] { + fill: none; +} + +input[type="checkbox"]:focus-visible+.emotion-22>[data-switch]>[data-switch-inner-border] { fill: black; } -input[type=checkbox]:hover:not(:disabled):not(:checked):not(:focus)+.emotion-22 [data-switch] [data-switch-track] { - fill: var(--tonic-colors-gray-50); +input[type="checkbox"]+.emotion-22>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-gray-60); } -input[type=checkbox]:disabled+.emotion-22 { - opacity: 0.28; +input[type="checkbox"]:hover:not(:disabled)+.emotion-22>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-gray-50); } -input[type=checkbox]:checked:hover:not(:disabled):not(:focus)+.emotion-22 [data-switch] [data-switch-track] { - fill: var(--tonic-colors-blue-50); +input[type="checkbox"]:checked:not(:disabled)+.emotion-22>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-blue-60); } -.emotion-22>* { - opacity: 1; +input[type="checkbox"]:checked:hover:not(:disabled)+.emotion-22>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-blue-50); } -input[type=checkbox]:checked+.emotion-22 [data-switch] [data-switch-track] { - fill: var(--tonic-colors-blue-60); +input[type="checkbox"]+.emotion-22>[data-switch]>[data-switch-thumb] { + fill: white; } -input[type=checkbox]:checked+.emotion-22 [data-switch] [data-switch-thumb] { +input[type="checkbox"]:checked+.emotion-22>[data-switch]>[data-switch-thumb] { -webkit-transform: translateX(24px); -moz-transform: translateX(24px); -ms-transform: translateX(24px); transform: translateX(24px); } -input[type=checkbox]:checked+.emotion-22>* { - opacity: 1; -} - -input[type=checkbox][data-indeterminate]+.emotion-22>* { - opacity: 1; -} - .emotion-26 { width: 54px; height: 30px; - fill: none; } .emotion-28 { width: 50px; height: 26px; - fill: none; } .emotion-30 { width: 48px; height: 24px; - fill: var(--tonic-colors-gray-60); pointer-events: all; } @@ -240,8 +237,6 @@ input[type=checkbox][data-indeterminate]+.emotion-22>* { -ms-flex-pack: center; -webkit-justify-content: center; justify-content: center; - -webkit-transition: all 120ms; - transition: all 120ms; -webkit-flex-shrink: 0; -ms-flex-negative: 0; flex-shrink: 0; @@ -253,65 +248,66 @@ input[type=checkbox][data-indeterminate]+.emotion-22>* { height: 38px; } -input[type=checkbox]:focus+.emotion-40 [data-switch] [data-switch-track-halo] { +input[type="checkbox"]:disabled+.emotion-40 { + opacity: 0.28; +} + +input[type="checkbox"]+.emotion-40>[data-switch]>[data-switch-outer-border] { + fill: none; +} + +input[type="checkbox"]:focus-visible+.emotion-40>[data-switch]>[data-switch-outer-border] { fill: var(--tonic-colors-blue-60); } -input[type=checkbox]:focus+.emotion-40 [data-switch] [data-switch-track-border] { +input[type="checkbox"]+.emotion-40>[data-switch]>[data-switch-inner-border] { + fill: none; +} + +input[type="checkbox"]:focus-visible+.emotion-40>[data-switch]>[data-switch-inner-border] { fill: black; } -input[type=checkbox]:hover:not(:disabled):not(:checked):not(:focus)+.emotion-40 [data-switch] [data-switch-track] { - fill: var(--tonic-colors-gray-50); +input[type="checkbox"]+.emotion-40>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-gray-60); } -input[type=checkbox]:disabled+.emotion-40 { - opacity: 0.28; +input[type="checkbox"]:hover:not(:disabled)+.emotion-40>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-gray-50); } -input[type=checkbox]:checked:hover:not(:disabled):not(:focus)+.emotion-40 [data-switch] [data-switch-track] { - fill: var(--tonic-colors-blue-50); +input[type="checkbox"]:checked:not(:disabled)+.emotion-40>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-blue-60); } -.emotion-40>* { - opacity: 1; +input[type="checkbox"]:checked:hover:not(:disabled)+.emotion-40>[data-switch]>[data-switch-track] { + fill: var(--tonic-colors-blue-50); } -input[type=checkbox]:checked+.emotion-40 [data-switch] [data-switch-track] { - fill: var(--tonic-colors-blue-60); +input[type="checkbox"]+.emotion-40>[data-switch]>[data-switch-thumb] { + fill: white; } -input[type=checkbox]:checked+.emotion-40 [data-switch] [data-switch-thumb] { +input[type="checkbox"]:checked+.emotion-40>[data-switch]>[data-switch-thumb] { -webkit-transform: translateX(32px); -moz-transform: translateX(32px); -ms-transform: translateX(32px); transform: translateX(32px); } -input[type=checkbox]:checked+.emotion-40>* { - opacity: 1; -} - -input[type=checkbox][data-indeterminate]+.emotion-40>* { - opacity: 1; -} - .emotion-44 { width: 70px; height: 38px; - fill: none; } .emotion-46 { width: 66px; height: 34px; - fill: none; } .emotion-48 { width: 64px; height: 32px; - fill: var(--tonic-colors-gray-60); pointer-events: all; } @@ -351,6 +347,7 @@ input[type=checkbox][data-indeterminate]+.emotion-40>* {