diff --git a/.changeset/stupid-carrots-jam.md b/.changeset/stupid-carrots-jam.md new file mode 100644 index 00000000000..0df7f9b5237 --- /dev/null +++ b/.changeset/stupid-carrots-jam.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Adds a toggle switch component diff --git a/docs/content/ToggleSwitch.mdx b/docs/content/ToggleSwitch.mdx new file mode 100644 index 00000000000..ae2764af93e --- /dev/null +++ b/docs/content/ToggleSwitch.mdx @@ -0,0 +1,220 @@ +--- +componentId: toggle_switch +title: ToggleSwitch +description: Toggles a setting on or off, and immediately saves the change +status: Alpha +source: https://github.com/primer/react/blob/main/src/ToggleSwitch.tsx +storybook: '/react/storybook?path=/story/toggleswitch-examples--default' +--- + +## Examples + +### Basic + +```jsx live + + + Notifications + + + +``` + +### Uncontrolled with default value + +```jsx live + + + Notifications + + + +``` + +### Controlled + +```javascript noinline live +const Controlled = () => { + const [isOn, setIsOn] = React.useState(false) + + const onClick = () => { + setIsOn(!isOn) + } + + const handleSwitchChange = on => { + console.log(`new switch "on" state: ${on}`) + } + + return ( + <> + + + Notifications + + + +

The switch is {isOn ? 'on' : 'off'}

+ + ) +} + +render(Controlled) +``` + +### Small + +```jsx live + + + Notifications + + + +``` + +### Delayed toggle with loading state + +```javascript noinline live +const LoadingToggle = () => { + const [loading, setLoading] = React.useState(false) + const [isOn, setIsOn] = React.useState(false) + + async function switchSlowly(currentOn) { + await new Promise(resolve => setTimeout(resolve, 1500)) + return await !currentOn + } + + async function onClick() { + setLoading(!loading) + const newSwitchState = await switchSlowly(isOn) + setIsOn(newSwitchState) + } + + const handleSwitchChange = React.useCallback( + on => { + setLoading(false) + }, + [setLoading] + ) + + return ( + <> + + + Notifications + + + +

The switch is {isOn ? 'on' : 'off'}

+ + ) +} + +render(LoadingToggle) +``` + +### Disabled + +```jsx live + + + Notifications + + + +``` + +### With associated caption text + +```jsx live + + + + Notifications + + + Notifications will be delivered via email and the GitHub notification center + + + + +``` + +### Left-aligned with label + +```jsx live +<> + + Notifications + + + +``` + +## Props + + + + + + + + + + + + +
Whether the "on" and "off" labels should appear before or after the switch.
+
+ This should only be changed when the switch's alignment needs to be adjusted.{' '} + For example: It needs to be left-aligned because the label appears above it and the caption appears below it. +
+ + } + /> +
+ +## Status + + diff --git a/docs/src/@primer/gatsby-theme-doctocat/nav.yml b/docs/src/@primer/gatsby-theme-doctocat/nav.yml index 0637cea346a..1f096be7ecc 100644 --- a/docs/src/@primer/gatsby-theme-doctocat/nav.yml +++ b/docs/src/@primer/gatsby-theme-doctocat/nav.yml @@ -123,6 +123,8 @@ url: /StyledOcticon - title: SubNav url: /SubNav + - title: ToggleSwitch + url: /ToggleSwitch - title: TabNav url: /TabNav - title: Textarea diff --git a/package-lock.json b/package-lock.json index 516123d6476..149f5b67255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@primer/react", - "version": "35.0.0", + "version": "35.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@primer/react", - "version": "35.0.0", + "version": "35.1.0", "license": "MIT", "dependencies": { "@primer/behaviors": "1.1.0", "@primer/octicons-react": "16.1.1", - "@primer/primitives": "7.5.1", + "@primer/primitives": "7.6.0", "@radix-ui/react-polymorphic": "0.0.14", "@react-aria/ssr": "3.1.0", "@styled-system/css": "5.1.5", @@ -5659,9 +5659,9 @@ } }, "node_modules/@primer/primitives": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-7.5.1.tgz", - "integrity": "sha512-1pFKR+FcYRPXJ+zK/qtidrCJB7WmTaAX4sG7zE5LvGWjS5latue4pzZrK0FxxGGBdAU3HpoabANsGjv7T7sRRg==" + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-7.6.0.tgz", + "integrity": "sha512-cu28QLjectVf2rT4P7m6zS9v4g4yHtErRuPfsgEWEJhbXVIII6vDBm6elJzYixGpTNxpVtSUNezxUXv16l1ejQ==" }, "node_modules/@radix-ui/react-polymorphic": { "version": "0.0.14", @@ -39292,9 +39292,9 @@ "requires": {} }, "@primer/primitives": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-7.5.1.tgz", - "integrity": "sha512-1pFKR+FcYRPXJ+zK/qtidrCJB7WmTaAX4sG7zE5LvGWjS5latue4pzZrK0FxxGGBdAU3HpoabANsGjv7T7sRRg==" + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-7.6.0.tgz", + "integrity": "sha512-cu28QLjectVf2rT4P7m6zS9v4g4yHtErRuPfsgEWEJhbXVIII6vDBm6elJzYixGpTNxpVtSUNezxUXv16l1ejQ==" }, "@radix-ui/react-polymorphic": { "version": "0.0.14", diff --git a/package.json b/package.json index f80909698e1..a131dec5295 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "dependencies": { "@primer/behaviors": "1.1.0", "@primer/octicons-react": "16.1.1", - "@primer/primitives": "7.5.1", + "@primer/primitives": "7.6.0", "@radix-ui/react-polymorphic": "0.0.14", "@react-aria/ssr": "3.1.0", "@styled-system/css": "5.1.5", diff --git a/src/Autocomplete/AutocompleteMenu.tsx b/src/Autocomplete/AutocompleteMenu.tsx index 22e99c61652..efe4bff8d25 100644 --- a/src/Autocomplete/AutocompleteMenu.tsx +++ b/src/Autocomplete/AutocompleteMenu.tsx @@ -23,7 +23,7 @@ function getDefaultItemFilter(filterValue: strin } } -function getDefaultOnSelectionChange( +function getdefaultCheckedSelectionChange( setInputValueFn: (value: string) => void ): OnSelectedChange { return function (itemOrItems) { @@ -160,7 +160,9 @@ function AutocompleteMenu(props: AutocompleteMe const newSelectedItemIds = selectedItemIds.includes(item.id) ? otherSelectedItemIds : [...otherSelectedItemIds, item.id] - const onSelectedChangeFn = onSelectedChange ? onSelectedChange : getDefaultOnSelectionChange(setInputValue) + const onSelectedChangeFn = onSelectedChange + ? onSelectedChange + : getdefaultCheckedSelectionChange(setInputValue) onSelectedChangeFn( newSelectedItemIds.map(newSelectedItemId => getItemById(newSelectedItemId, items)) as T[] diff --git a/src/ToggleSwitch.tsx b/src/ToggleSwitch.tsx new file mode 100644 index 00000000000..14e07f1e006 --- /dev/null +++ b/src/ToggleSwitch.tsx @@ -0,0 +1,329 @@ +import React, {MouseEventHandler, useCallback, useEffect} from 'react' +import styled, {css} from 'styled-components' +import {variant} from 'styled-system' +import {Box, Spinner, Text} from '.' +import {get} from './constants' +import {useProvidedStateOrCreate} from './hooks' +import sx, {BetterSystemStyleObject, SxProp} from './sx' +import VisuallyHidden from './_VisuallyHidden' + +const TRANSITION_DURATION = '80ms' +const EASE_OUT_QUAD_CURVE = 'cubic-bezier(0.5, 1, 0.89, 1)' + +type SwitchProps = { + /** The id of the DOM node that describes the switch */ + ['aria-describedby']?: string + /** The id of the DOM node that labels the switch */ + ['aria-labelledby']: string + /** Uncontrolled - whether the switch is turned on */ + defaultChecked?: boolean + /** Whether the switch is ready for user input */ + disabled?: boolean + /** Whether the switch's value is being calculated */ + loading?: boolean + /** Whether the switch is turned on */ + checked?: boolean + /** The callback that is called when the switch is toggled on or off */ + onChange?: (on: boolean) => void + /** The callback that is called when the switch is clicked */ + onClick?: MouseEventHandler + /** Size of the switch */ + size?: 'small' | 'medium' + /** Whether the "on" and "off" labels should appear before or after the switch. + * **This should only be changed when the switch's alignment needs to be adjusted.** For example: It needs to be left-aligned because the label appears above it and the caption appears below it. + */ + statusLabelPosition?: 'start' | 'end' +} & SxProp + +const sizeVariants = variant({ + prop: 'size', + variants: { + small: { + height: '24px', + width: '48px' + } + } +}) + +type SwitchButtonProps = { + disabled?: boolean + checked?: boolean + size?: SwitchProps['size'] +} & SxProp + +type InnerIconProps = {size?: SwitchProps['size']} + +const CircleIcon: React.FC = ({size}) => ( + + + +) +const LineIcon: React.FC = ({size}) => ( + + + +) + +const SwitchButton = styled.button` + vertical-align: middle; + cursor: pointer; + user-select: none; + appearance: none; + text-decoration: none; + padding: 0; + transition-property: background-color, border-color; + transition-duration: ${TRANSITION_DURATION}; + transition-timing-function: ${EASE_OUT_QUAD_CURVE}; + border-radius: ${get('radii.2')}; + border-style: solid; + border-width: 1px; + display: block; + height: 32px; + width: 64px; + outline-offset: 2px; + position: relative; + + @media (pointer: coarse) { + &:before { + content: ''; + position: absolute; + left: 0; + right: 0; + transform: translateY(-50%); + top: 50%; + min-height: 44px; + } + } + + @media (prefers-reduced-motion) { + transition: none; + + * { + transition: none; + } + } + + &:after { + content: ''; + box-sizing: border-box; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: calc(${get('radii.2')} - 1px); /* -1px to account for 1px border around the control */ + } + + ${props => { + if (props.disabled) { + return css` + background-color: ${get('colors.canvas.subtle')}; + border-color: ${get('colors.border.subtle')}; + cursor: not-allowed; + transition-property: none; + ` + } + + if (props.checked) { + return css` + background-color: ${get('colors.switchTrack.checked.bg')}; + border-color: ${get('colors.switchTrack.checked.border')}; + + &:hover, + &:focus:focus-visible { + background-color: ${get('colors.switchTrack.checked.hoverBg')}; + } + + &:active, + &:active:focus-visible { + background-color: ${get('colors.switchTrack.checked.activeBg')}; + } + ` + } else { + return css` + background-color: ${get('colors.switchTrack.bg')}; + border-color: ${get('colors.switchTrack.border')}; + + &:hover, + &:focus:focus-visible { + .Toggle-knob { + background-color: ${get('colors.btn.hoverBg')}; + } + } + + &:active, + &:active:focus-visible { + .Toggle-knob { + background-color: ${get('colors.btn.activeBg')}; + } + } + ` + } + }} + + ${sx} + ${sizeVariants} +` + +const ToggleKnob = styled.div<{checked?: boolean; disabled?: boolean}>` + background-color: ${get('colors.btn.bg')}; + border-width: 1px; + border-style: solid; + border-color: ${props => (props.disabled ? get('colors.border.default') : get('colors.switchTrack.border'))}; + border-radius: calc(${get('radii.2')} - 1px); /* -1px to account for 1px border around the control */ + box-shadow: ${props => + props.disabled ? 'none' : `${props.theme?.shadows?.shadow.medium}, ${props.theme?.shadows?.btn.insetShadow}`}; + width: 50%; + position: absolute; + top: -1px; + bottom: -1px; + transition-property: transform; + transition-duration: ${TRANSITION_DURATION}; + transition-timing-function: ${EASE_OUT_QUAD_CURVE}; + transform: ${props => `translateX(${props.checked ? 'calc(100% + 1px)' : '-1px'})`}; + z-index: 1; + + @media (prefers-reduced-motion) { + transition: none; + } + + ${props => { + if (props.checked) { + return css` + background-color: ${props.disabled + ? get('colors.switchKnob.checked.disabledBg') + : get('colors.switchKnob.checked.bg')}; + border-color: ${props.disabled + ? get('colors.switchKnob.checked.disabledBg') + : get('colors.switchKnob.checked.bg')}; + box-shadow: ${get('shadows.shadow.small')}; + ` + } + }} +` + +const hiddenTextStyles: BetterSystemStyleObject = { + visibility: 'hidden', + height: 0 +} + +const Switch: React.FC = ({ + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + defaultChecked, + disabled, + loading, + checked, + onChange, + onClick, + size, + statusLabelPosition, + sx: sxProp +}) => { + const isControlled = typeof checked !== 'undefined' + const [isOn, setIsOn] = useProvidedStateOrCreate(checked, onChange, Boolean(defaultChecked)) + const acceptsInteraction = !disabled && !loading + const handleToggleClick: MouseEventHandler = useCallback( + e => { + if (!isControlled) { + setIsOn(!isOn) + } + onClick && onClick(e) + }, + [onClick, isControlled, isOn, setIsOn] + ) + + useEffect(() => { + if (onChange && isControlled) { + onChange(Boolean(checked)) + } + }, [onChange, checked, isControlled]) + + return ( + + {loading ? : null} + + + {isOn ? 'On' : 'Off'} + + + + ) +} + +Switch.defaultProps = { + statusLabelPosition: 'start', + size: 'medium' +} + +export default Switch diff --git a/src/__tests__/ToggleSwitch.test.tsx b/src/__tests__/ToggleSwitch.test.tsx new file mode 100644 index 00000000000..65816bae599 --- /dev/null +++ b/src/__tests__/ToggleSwitch.test.tsx @@ -0,0 +1,128 @@ +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import {render} from '@testing-library/react' +import {ToggleSwitch} from '..' +import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing' +import userEvent from '@testing-library/user-event' + +const SWITCH_LABEL_TEXT = 'Switch label' + +behavesAsComponent({ + Component: ToggleSwitch, + options: {skipAs: true} +}) +checkExports('ToggleSwitch', { + default: ToggleSwitch +}) +it('renders a switch that is turned off', () => { + const {getByLabelText} = render( + <> +
{SWITCH_LABEL_TEXT}
+ + + ) + const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT) + + expect(toggleSwitch).toHaveAttribute('aria-checked', 'false') +}) +it('renders a switch that is turned on', () => { + const {getByLabelText} = render( + <> +
{SWITCH_LABEL_TEXT}
+ + + ) + const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT) + + expect(toggleSwitch).toHaveAttribute('aria-checked', 'true') +}) +it('renders a switch that is disabled', () => { + const {getByLabelText} = render( + <> +
{SWITCH_LABEL_TEXT}
+ + + ) + const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT) + + expect(toggleSwitch).toHaveAttribute('aria-disabled', 'true') + expect(toggleSwitch).toHaveAttribute('aria-checked', 'false') + userEvent.click(toggleSwitch) + expect(toggleSwitch).toHaveAttribute('aria-checked', 'false') +}) +it("renders a switch who's state is loading", () => { + const {getByLabelText, container} = render( + <> +
{SWITCH_LABEL_TEXT}
+ + + ) + const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT) + const loadingSpinner = container.querySelector('svg') + + expect(loadingSpinner).toBeDefined() + expect(toggleSwitch).toHaveAttribute('aria-disabled', 'true') + expect(toggleSwitch).toHaveAttribute('aria-checked', 'false') + userEvent.click(toggleSwitch) + expect(toggleSwitch).toHaveAttribute('aria-checked', 'false') +}) +it('switches from off to on uncontrolled', () => { + const {getByLabelText} = render( + <> +
{SWITCH_LABEL_TEXT}
+ + + ) + const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT) + + expect(toggleSwitch).toHaveAttribute('aria-checked', 'false') + userEvent.click(toggleSwitch) + expect(toggleSwitch).toHaveAttribute('aria-checked', 'true') +}) +it('switches from off to on with a controlled prop', () => { + const ControlledSwitchComponent = () => { + const [isOn, setIsOn] = React.useState(false) + + const onClick = () => { + setIsOn(!isOn) + } + + return ( + <> +
{SWITCH_LABEL_TEXT}
+ + + ) + } + const {getByLabelText} = render() + const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT) + + expect(toggleSwitch).toHaveAttribute('aria-checked', 'false') + userEvent.click(toggleSwitch) + expect(toggleSwitch).toHaveAttribute('aria-checked', 'true') +}) +it('calls onChange when the switch is toggled', () => { + const handleChange = jest.fn() + const ControlledSwitchComponent = ({handleSwitchChange}: {handleSwitchChange: (on: boolean) => void}) => { + const [isOn, setIsOn] = React.useState(false) + + const onClick = () => { + setIsOn(!isOn) + } + + return ( + <> +
{SWITCH_LABEL_TEXT}
+ + + ) + } + const {getByLabelText} = render() + const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT) + + userEvent.click(toggleSwitch) + expect(handleChange).toHaveBeenCalledWith(true) +}) + +checkStoriesForAxeViolations('Switch/fixtures') +checkStoriesForAxeViolations('Switch/examples') diff --git a/src/__tests__/__snapshots__/ToggleSwitch.test.tsx.snap b/src/__tests__/__snapshots__/ToggleSwitch.test.tsx.snap new file mode 100644 index 00000000000..ef3575ba286 --- /dev/null +++ b/src/__tests__/__snapshots__/ToggleSwitch.test.tsx.snap @@ -0,0 +1,305 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders consistently 1`] = ` +.c2 { + text-align: right; + visibility: hidden; + height: 0; +} + +.c3 { + text-align: right; +} + +.c6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + width: 100%; + height: 100%; + overflow: hidden; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.c7 { + color: #0969da; + line-height: 0; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + -webkit-flex-basis: 50%; + -ms-flex-preferred-size: 50%; + flex-basis: 50%; + -webkit-transform: translateX(-100%); + -ms-transform: translateX(-100%); + transform: translateX(-100%); + -webkit-transition-property: -webkit-transform; + -webkit-transition-property: transform; + transition-property: transform; + -webkit-transition-duration: 80ms; + transition-duration: 80ms; +} + +.c8 { + color: #24292f; + line-height: 0; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + -webkit-flex-basis: 50%; + -ms-flex-preferred-size: 50%; + flex-basis: 50%; + -webkit-transform: translateX(0); + -ms-transform: translateX(0); + transform: translateX(0); + -webkit-transition-property: -webkit-transform; + -webkit-transition-property: transform; + transition-property: transform; + -webkit-transition-duration: 80ms; + transition-duration: 80ms; +} + +.c0 { + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; +} + +.c5 { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + -webkit-clip: rect(0,0,0,0); + clip: rect(0,0,0,0); + white-space: nowrap; + border-width: 0; +} + +.c1 { + font-size: 14px; + color: #24292f; + margin-left: 8px; + margin-right: 8px; + position: relative; +} + +.c4 { + vertical-align: middle; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + -webkit-text-decoration: none; + text-decoration: none; + padding: 0; + -webkit-transition-property: background-color,border-color; + transition-property: background-color,border-color; + -webkit-transition-duration: 80ms; + transition-duration: 80ms; + -webkit-transition-timing-function: cubic-bezier(0.5,1,0.89,1); + transition-timing-function: cubic-bezier(0.5,1,0.89,1); + border-radius: 6px; + border-style: solid; + border-width: 1px; + display: block; + height: 32px; + width: 64px; + outline-offset: 2px; + position: relative; + background-color: #eaeef2; + border-color: #afb8c1; +} + +.c4:after { + content: ''; + box-sizing: border-box; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: calc(6px - 1px); +} + +.c4:hover .Toggle-knob, +.c4:focus:focus-visible .Toggle-knob { + background-color: #f3f4f6; +} + +.c4:active .Toggle-knob, +.c4:active:focus-visible .Toggle-knob { + background-color: hsla(220,14%,93%,1); +} + +.c9 { + background-color: #f6f8fa; + border-width: 1px; + border-style: solid; + border-color: #afb8c1; + border-radius: calc(6px - 1px); + box-shadow: 0 3px 6px rgba(140,149,159,0.15),inset 0 1px 0 rgba(255,255,255,0.25); + width: 50%; + position: absolute; + top: -1px; + bottom: -1px; + -webkit-transition-property: -webkit-transform; + -webkit-transition-property: transform; + transition-property: transform; + -webkit-transition-duration: 80ms; + transition-duration: 80ms; + -webkit-transition-timing-function: cubic-bezier(0.5,1,0.89,1); + transition-timing-function: cubic-bezier(0.5,1,0.89,1); + -webkit-transform: translateX(-1px); + -ms-transform: translateX(-1px); + transform: translateX(-1px); + z-index: 1; +} + +@media (pointer:coarse) { + .c4:before { + content: ''; + position: absolute; + left: 0; + right: 0; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + top: 50%; + min-height: 44px; + } +} + +@media (prefers-reduced-motion) { + .c4 { + -webkit-transition: none; + transition: none; + } + + .c4 * { + -webkit-transition: none; + transition: none; + } +} + +@media (prefers-reduced-motion) { + .c9 { + -webkit-transition: none; + transition: none; + } +} + +
+ + +
+`; diff --git a/src/__tests__/__snapshots__/themePreval.test.ts.snap b/src/__tests__/__snapshots__/themePreval.test.ts.snap index fb64ee82db7..40363654676 100644 --- a/src/__tests__/__snapshots__/themePreval.test.ts.snap +++ b/src/__tests__/__snapshots__/themePreval.test.ts.snap @@ -3,7 +3,7 @@ exports[`snapshot theme-preval.js 1`] = ` "// this file was prevaled // This file needs to be a JavaScript file using CommonJS to be compatible with preval -// Cache bust: 2022-03-14 12:00:00 GMT (This file is cached by our deployment tooling, update this timestamp to rebuild this file) +// Cache bust: 2022-03-24 12:00:00 GMT (This file is cached by our deployment tooling, update this timestamp to rebuild this file) module.exports = { \\"theme\\": { \\"animation\\": { @@ -353,6 +353,22 @@ module.exports = { \\"hoverText\\": \\"#cf222e\\" } }, + \\"switchTrack\\": { + \\"bg\\": \\"#eaeef2\\", + \\"border\\": \\"#afb8c1\\", + \\"checked\\": { + \\"bg\\": \\"#ddf4ff\\", + \\"hoverBg\\": \\"#b6e3ff\\", + \\"activeBg\\": \\"#80ccff\\", + \\"border\\": \\"#54aeff\\" + } + }, + \\"switchKnob\\": { + \\"checked\\": { + \\"bg\\": \\"#0969da\\", + \\"disabledBg\\": \\"#6e7781\\" + } + }, \\"fg\\": { \\"default\\": \\"#24292f\\", \\"muted\\": \\"#57606a\\", @@ -818,6 +834,22 @@ module.exports = { \\"hoverText\\": \\"#ffffff\\" } }, + \\"switchTrack\\": { + \\"bg\\": \\"#ffffff\\", + \\"border\\": \\"#20252C\\", + \\"checked\\": { + \\"bg\\": \\"#dff7ff\\", + \\"hoverBg\\": \\"#9cd7ff\\", + \\"activeBg\\": \\"#67b3fd\\", + \\"border\\": \\"#0349b4\\" + } + }, + \\"switchKnob\\": { + \\"checked\\": { + \\"bg\\": \\"#0349b4\\", + \\"disabledBg\\": \\"#66707B\\" + } + }, \\"fg\\": { \\"default\\": \\"#0E1116\\", \\"muted\\": \\"#0E1116\\", @@ -1283,6 +1315,22 @@ module.exports = { \\"hoverText\\": \\"#b35900\\" } }, + \\"switchTrack\\": { + \\"bg\\": \\"#eaeef2\\", + \\"border\\": \\"#afb8c1\\", + \\"checked\\": { + \\"bg\\": \\"#ddf4ff\\", + \\"hoverBg\\": \\"#b6e3ff\\", + \\"activeBg\\": \\"#80ccff\\", + \\"border\\": \\"#54aeff\\" + } + }, + \\"switchKnob\\": { + \\"checked\\": { + \\"bg\\": \\"#0969da\\", + \\"disabledBg\\": \\"#6e7781\\" + } + }, \\"fg\\": { \\"default\\": \\"#24292f\\", \\"muted\\": \\"#57606a\\", @@ -1508,7 +1556,7 @@ module.exports = { \\"bg\\": \\"#0d1117\\", \\"guttersBg\\": \\"#0d1117\\", \\"guttermarkerText\\": \\"#0d1117\\", - \\"guttermarkerSubtleText\\": \\"#484f58\\", + \\"guttermarkerSubtleText\\": \\"#6e7681\\", \\"linenumberText\\": \\"#8b949e\\", \\"cursor\\": \\"#c9d1d9\\", \\"selectionBg\\": \\"rgba(56,139,253,0.4)\\", @@ -1535,7 +1583,7 @@ module.exports = { \\"btnHoverIcon\\": \\"#c9d1d9\\", \\"btnHoverBg\\": \\"rgba(110,118,129,0.1)\\", \\"inputText\\": \\"#8b949e\\", - \\"inputPlaceholderText\\": \\"#484f58\\", + \\"inputPlaceholderText\\": \\"#6e7681\\", \\"inputFocusText\\": \\"#c9d1d9\\", \\"inputBg\\": \\"#161b22\\", \\"donutError\\": \\"#f85149\\", @@ -1556,8 +1604,8 @@ module.exports = { \\"headerBorder\\": \\"#21262d\\", \\"headerIcon\\": \\"#8b949e\\", \\"lineText\\": \\"#8b949e\\", - \\"lineNumText\\": \\"#484f58\\", - \\"lineTimestampText\\": \\"#484f58\\", + \\"lineNumText\\": \\"#6e7681\\", + \\"lineTimestampText\\": \\"#6e7681\\", \\"lineHoverBg\\": \\"rgba(110,118,129,0.1)\\", \\"lineSelectedBg\\": \\"rgba(56,139,253,0.15)\\", \\"lineSelectedNumText\\": \\"#58a6ff\\", @@ -1570,10 +1618,10 @@ module.exports = { \\"stepErrorText\\": \\"#f85149\\", \\"stepWarningText\\": \\"#d29922\\", \\"loglineText\\": \\"#8b949e\\", - \\"loglineNumText\\": \\"#484f58\\", + \\"loglineNumText\\": \\"#6e7681\\", \\"loglineDebugText\\": \\"#a371f7\\", \\"loglineErrorText\\": \\"#8b949e\\", - \\"loglineErrorNumText\\": \\"#484f58\\", + \\"loglineErrorNumText\\": \\"#6e7681\\", \\"loglineErrorBg\\": \\"rgba(248,81,73,0.15)\\", \\"loglineWarningText\\": \\"#8b949e\\", \\"loglineWarningNumText\\": \\"#d29922\\", @@ -1730,7 +1778,7 @@ module.exports = { } }, \\"underlinenav\\": { - \\"icon\\": \\"#484f58\\", + \\"icon\\": \\"#6e7681\\", \\"borderHover\\": \\"rgba(110,118,129,0.4)\\" }, \\"actionListItem\\": { @@ -1748,10 +1796,26 @@ module.exports = { \\"hoverText\\": \\"#ff7b72\\" } }, + \\"switchTrack\\": { + \\"bg\\": \\"#010409\\", + \\"border\\": \\"#6e7681\\", + \\"checked\\": { + \\"bg\\": \\"rgba(31,111,235,0.35)\\", + \\"hoverBg\\": \\"rgba(31,111,235,0.5)\\", + \\"activeBg\\": \\"rgba(31,111,235,0.65)\\", + \\"border\\": \\"#58a6ff\\" + } + }, + \\"switchKnob\\": { + \\"checked\\": { + \\"bg\\": \\"#1f6feb\\", + \\"disabledBg\\": \\"#484f58\\" + } + }, \\"fg\\": { \\"default\\": \\"#c9d1d9\\", \\"muted\\": \\"#8b949e\\", - \\"subtle\\": \\"#484f58\\", + \\"subtle\\": \\"#6e7681\\", \\"onEmphasis\\": \\"#ffffff\\" }, \\"canvas\\": { @@ -1976,7 +2040,7 @@ module.exports = { \\"bg\\": \\"#22272e\\", \\"guttersBg\\": \\"#22272e\\", \\"guttermarkerText\\": \\"#22272e\\", - \\"guttermarkerSubtleText\\": \\"#545d68\\", + \\"guttermarkerSubtleText\\": \\"#636e7b\\", \\"linenumberText\\": \\"#768390\\", \\"cursor\\": \\"#adbac7\\", \\"selectionBg\\": \\"rgba(65,132,228,0.4)\\", @@ -2003,7 +2067,7 @@ module.exports = { \\"btnHoverIcon\\": \\"#adbac7\\", \\"btnHoverBg\\": \\"rgba(99,110,123,0.1)\\", \\"inputText\\": \\"#768390\\", - \\"inputPlaceholderText\\": \\"#545d68\\", + \\"inputPlaceholderText\\": \\"#636e7b\\", \\"inputFocusText\\": \\"#adbac7\\", \\"inputBg\\": \\"#2d333b\\", \\"donutError\\": \\"#e5534b\\", @@ -2024,8 +2088,8 @@ module.exports = { \\"headerBorder\\": \\"#373e47\\", \\"headerIcon\\": \\"#768390\\", \\"lineText\\": \\"#768390\\", - \\"lineNumText\\": \\"#545d68\\", - \\"lineTimestampText\\": \\"#545d68\\", + \\"lineNumText\\": \\"#636e7b\\", + \\"lineTimestampText\\": \\"#636e7b\\", \\"lineHoverBg\\": \\"rgba(99,110,123,0.1)\\", \\"lineSelectedBg\\": \\"rgba(65,132,228,0.15)\\", \\"lineSelectedNumText\\": \\"#539bf5\\", @@ -2038,10 +2102,10 @@ module.exports = { \\"stepErrorText\\": \\"#e5534b\\", \\"stepWarningText\\": \\"#c69026\\", \\"loglineText\\": \\"#768390\\", - \\"loglineNumText\\": \\"#545d68\\", + \\"loglineNumText\\": \\"#636e7b\\", \\"loglineDebugText\\": \\"#986ee2\\", \\"loglineErrorText\\": \\"#768390\\", - \\"loglineErrorNumText\\": \\"#545d68\\", + \\"loglineErrorNumText\\": \\"#636e7b\\", \\"loglineErrorBg\\": \\"rgba(229,83,75,0.15)\\", \\"loglineWarningText\\": \\"#768390\\", \\"loglineWarningNumText\\": \\"#c69026\\", @@ -2198,7 +2262,7 @@ module.exports = { } }, \\"underlinenav\\": { - \\"icon\\": \\"#545d68\\", + \\"icon\\": \\"#636e7b\\", \\"borderHover\\": \\"rgba(99,110,123,0.4)\\" }, \\"actionListItem\\": { @@ -2216,10 +2280,26 @@ module.exports = { \\"hoverText\\": \\"#f47067\\" } }, + \\"switchTrack\\": { + \\"bg\\": \\"#1c2128\\", + \\"border\\": \\"#636e7b\\", + \\"checked\\": { + \\"bg\\": \\"rgba(49,109,202,0.35)\\", + \\"hoverBg\\": \\"rgba(49,109,202,0.5)\\", + \\"activeBg\\": \\"rgba(49,109,202,0.65)\\", + \\"border\\": \\"#539bf5\\" + } + }, + \\"switchKnob\\": { + \\"checked\\": { + \\"bg\\": \\"#316dca\\", + \\"disabledBg\\": \\"#545d68\\" + } + }, \\"fg\\": { \\"default\\": \\"#adbac7\\", \\"muted\\": \\"#768390\\", - \\"subtle\\": \\"#545d68\\", + \\"subtle\\": \\"#636e7b\\", \\"onEmphasis\\": \\"#cdd9e5\\" }, \\"canvas\\": { @@ -2444,7 +2524,7 @@ module.exports = { \\"bg\\": \\"#0a0c10\\", \\"guttersBg\\": \\"#0a0c10\\", \\"guttermarkerText\\": \\"#0a0c10\\", - \\"guttermarkerSubtleText\\": \\"#7a828e\\", + \\"guttermarkerSubtleText\\": \\"#9ea7b3\\", \\"linenumberText\\": \\"#f0f3f6\\", \\"cursor\\": \\"#f0f3f6\\", \\"selectionBg\\": \\"rgba(64,158,255,0.4)\\", @@ -2471,7 +2551,7 @@ module.exports = { \\"btnHoverIcon\\": \\"#f0f3f6\\", \\"btnHoverBg\\": \\"rgba(158,167,179,0.1)\\", \\"inputText\\": \\"#f0f3f6\\", - \\"inputPlaceholderText\\": \\"#7a828e\\", + \\"inputPlaceholderText\\": \\"#9ea7b3\\", \\"inputFocusText\\": \\"#f0f3f6\\", \\"inputBg\\": \\"#272b33\\", \\"donutError\\": \\"#ff6a69\\", @@ -2492,8 +2572,8 @@ module.exports = { \\"headerBorder\\": \\"#7a828e\\", \\"headerIcon\\": \\"#f0f3f6\\", \\"lineText\\": \\"#f0f3f6\\", - \\"lineNumText\\": \\"#7a828e\\", - \\"lineTimestampText\\": \\"#7a828e\\", + \\"lineNumText\\": \\"#9ea7b3\\", + \\"lineTimestampText\\": \\"#9ea7b3\\", \\"lineHoverBg\\": \\"rgba(158,167,179,0.1)\\", \\"lineSelectedBg\\": \\"rgba(64,158,255,0.15)\\", \\"lineSelectedNumText\\": \\"#71b7ff\\", @@ -2506,10 +2586,10 @@ module.exports = { \\"stepErrorText\\": \\"#ff6a69\\", \\"stepWarningText\\": \\"#f0b72f\\", \\"loglineText\\": \\"#f0f3f6\\", - \\"loglineNumText\\": \\"#7a828e\\", + \\"loglineNumText\\": \\"#9ea7b3\\", \\"loglineDebugText\\": \\"#b780ff\\", \\"loglineErrorText\\": \\"#f0f3f6\\", - \\"loglineErrorNumText\\": \\"#7a828e\\", + \\"loglineErrorNumText\\": \\"#9ea7b3\\", \\"loglineErrorBg\\": \\"rgba(255,106,105,0.15)\\", \\"loglineWarningText\\": \\"#f0f3f6\\", \\"loglineWarningNumText\\": \\"#f0b72f\\", @@ -2684,10 +2764,26 @@ module.exports = { \\"hoverText\\": \\"#0a0c10\\" } }, + \\"switchTrack\\": { + \\"bg\\": \\"#010409\\", + \\"border\\": \\"#7a828e\\", + \\"checked\\": { + \\"bg\\": \\"rgba(64,158,255,0.35)\\", + \\"hoverBg\\": \\"rgba(64,158,255,0.5)\\", + \\"activeBg\\": \\"rgba(64,158,255,0.65)\\", + \\"border\\": \\"#409eff\\" + } + }, + \\"switchKnob\\": { + \\"checked\\": { + \\"bg\\": \\"#409eff\\", + \\"disabledBg\\": \\"#7a828e\\" + } + }, \\"fg\\": { \\"default\\": \\"#f0f3f6\\", \\"muted\\": \\"#f0f3f6\\", - \\"subtle\\": \\"#7a828e\\", + \\"subtle\\": \\"#9ea7b3\\", \\"onEmphasis\\": \\"#0a0c10\\" }, \\"canvas\\": { @@ -2912,7 +3008,7 @@ module.exports = { \\"bg\\": \\"#0d1117\\", \\"guttersBg\\": \\"#0d1117\\", \\"guttermarkerText\\": \\"#0d1117\\", - \\"guttermarkerSubtleText\\": \\"#484f58\\", + \\"guttermarkerSubtleText\\": \\"#6e7681\\", \\"linenumberText\\": \\"#8b949e\\", \\"cursor\\": \\"#c9d1d9\\", \\"selectionBg\\": \\"rgba(56,139,253,0.4)\\", @@ -2939,7 +3035,7 @@ module.exports = { \\"btnHoverIcon\\": \\"#c9d1d9\\", \\"btnHoverBg\\": \\"rgba(110,118,129,0.1)\\", \\"inputText\\": \\"#8b949e\\", - \\"inputPlaceholderText\\": \\"#484f58\\", + \\"inputPlaceholderText\\": \\"#6e7681\\", \\"inputFocusText\\": \\"#c9d1d9\\", \\"inputBg\\": \\"#161b22\\", \\"donutError\\": \\"#d47616\\", @@ -2960,8 +3056,8 @@ module.exports = { \\"headerBorder\\": \\"#21262d\\", \\"headerIcon\\": \\"#8b949e\\", \\"lineText\\": \\"#8b949e\\", - \\"lineNumText\\": \\"#484f58\\", - \\"lineTimestampText\\": \\"#484f58\\", + \\"lineNumText\\": \\"#6e7681\\", + \\"lineTimestampText\\": \\"#6e7681\\", \\"lineHoverBg\\": \\"rgba(110,118,129,0.1)\\", \\"lineSelectedBg\\": \\"rgba(56,139,253,0.15)\\", \\"lineSelectedNumText\\": \\"#58a6ff\\", @@ -2974,10 +3070,10 @@ module.exports = { \\"stepErrorText\\": \\"#d47616\\", \\"stepWarningText\\": \\"#d29922\\", \\"loglineText\\": \\"#8b949e\\", - \\"loglineNumText\\": \\"#484f58\\", + \\"loglineNumText\\": \\"#6e7681\\", \\"loglineDebugText\\": \\"#a371f7\\", \\"loglineErrorText\\": \\"#8b949e\\", - \\"loglineErrorNumText\\": \\"#484f58\\", + \\"loglineErrorNumText\\": \\"#6e7681\\", \\"loglineErrorBg\\": \\"rgba(212,118,22,0.15)\\", \\"loglineWarningText\\": \\"#8b949e\\", \\"loglineWarningNumText\\": \\"#d29922\\", @@ -3134,7 +3230,7 @@ module.exports = { } }, \\"underlinenav\\": { - \\"icon\\": \\"#484f58\\", + \\"icon\\": \\"#6e7681\\", \\"borderHover\\": \\"rgba(110,118,129,0.4)\\" }, \\"actionListItem\\": { @@ -3152,10 +3248,26 @@ module.exports = { \\"hoverText\\": \\"#ec8e2c\\" } }, + \\"switchTrack\\": { + \\"bg\\": \\"#010409\\", + \\"border\\": \\"#6e7681\\", + \\"checked\\": { + \\"bg\\": \\"rgba(31,111,235,0.35)\\", + \\"hoverBg\\": \\"rgba(31,111,235,0.5)\\", + \\"activeBg\\": \\"rgba(31,111,235,0.65)\\", + \\"border\\": \\"#58a6ff\\" + } + }, + \\"switchKnob\\": { + \\"checked\\": { + \\"bg\\": \\"#1f6feb\\", + \\"disabledBg\\": \\"#484f58\\" + } + }, \\"fg\\": { \\"default\\": \\"#c9d1d9\\", \\"muted\\": \\"#8b949e\\", - \\"subtle\\": \\"#484f58\\", + \\"subtle\\": \\"#6e7681\\", \\"onEmphasis\\": \\"#ffffff\\" }, \\"canvas\\": { diff --git a/src/hooks/useOverlay.tsx b/src/hooks/useOverlay.tsx index ef8ecf3010a..c4d5c367d39 100644 --- a/src/hooks/useOverlay.tsx +++ b/src/hooks/useOverlay.tsx @@ -31,10 +31,10 @@ export const useOverlay = ({ useOnOutsideClick({containerRef: overlayRef, ignoreClickRefs, onClickOutside}) // We only want one overlay to close at a time - const preventedDefaultOnEscape: UseOverlaySettings['onEscape'] = event => { + const preventeddefaultCheckedEscape: UseOverlaySettings['onEscape'] = event => { onEscape(event) event.preventDefault() } - useOnEscapePress(preventedDefaultOnEscape) + useOnEscapePress(preventeddefaultCheckedEscape) return {ref: overlayRef} } diff --git a/src/index.ts b/src/index.ts index 43a8481f2d6..f4ce005e4f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -124,6 +124,7 @@ export {default as StyledOcticon} from './StyledOcticon' export type {StyledOcticonProps} from './StyledOcticon' export {default as SubNav} from './SubNav' export type {SubNavProps, SubNavLinkProps, SubNavLinksProps} from './SubNav' +export {default as ToggleSwitch} from './ToggleSwitch' export {default as TabNav} from './TabNav' export type {TabNavProps, TabNavLinkProps} from './TabNav' export {default as TextInput} from './TextInput' diff --git a/src/stories/Switch/examples.stories.tsx b/src/stories/Switch/examples.stories.tsx new file mode 100644 index 00000000000..c8d6003ee15 --- /dev/null +++ b/src/stories/Switch/examples.stories.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import {Meta} from '@storybook/react' + +import {BaseStyles, Box, ToggleSwitch, Text, ThemeProvider} from '../../' +import {ComponentProps} from '../../utils/types' +import {action} from '@storybook/addon-actions' + +type Args = ComponentProps + +const excludedControlKeys = [ + 'aria-describedby', + 'aria-labelledby', + 'defaultChecked', + 'onChange', + 'onClick', + 'statusLabelPosition', + 'sx' +] + +export default { + title: 'ToggleSwitch/examples', + component: ToggleSwitch, + argTypes: { + on: { + defaultValue: undefined, + control: { + type: 'boolean' + } + }, + disabled: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + loading: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + size: { + defaultValue: 'medium', + control: { + type: 'radio', + options: ['small', 'medium'] + } + } + }, + parameters: {controls: {exclude: excludedControlKeys}}, + decorators: [ + Story => { + return ( + + + + + + ) + } + ] +} as Meta + +export const Default = (args: Args) => ( + + + Notifications + + + +) +Default.storyName = 'Default (uncontrolled)' + +export const Controlled = (args: Args) => { + const [isOn, setIsOn] = React.useState(false) + + const onClick = React.useCallback(() => { + setIsOn(!isOn) + }, [setIsOn, isOn]) + + const handleSwitchChange = (on: boolean) => { + action(`new switch "on" state: ${on}`) + } + + return ( + <> + + + Notifications + + + +

The switch is {isOn ? 'on' : 'off'}

+ + ) +} +Controlled.parameters = {controls: {exclude: [...excludedControlKeys, 'on']}} + +export const StatusLabelPositionedAtEnd = (args: Args) => ( + <> + + Notifications + + + +) +StatusLabelPositionedAtEnd.storyName = 'statusLabelPosition="end"' diff --git a/src/stories/Switch/fixtures.stories.tsx b/src/stories/Switch/fixtures.stories.tsx new file mode 100644 index 00000000000..bf6b27049be --- /dev/null +++ b/src/stories/Switch/fixtures.stories.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import {Meta} from '@storybook/react' + +import {BaseStyles, Box, ToggleSwitch, Text, ThemeProvider} from '../../' +import {ComponentProps} from '../../utils/types' + +type Args = ComponentProps + +export default { + title: 'ToggleSwitch/fixtrues', + component: ToggleSwitch, + argTypes: { + on: { + defaultValue: undefined, + control: { + type: 'boolean' + } + }, + disabled: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + loading: { + defaultValue: false, + control: { + type: 'boolean' + } + }, + size: { + control: { + type: 'radio', + options: ['small', 'medium'] + } + } + }, + parameters: { + controls: { + exclude: ['aria-describedby', 'aria-labelledby', 'defaultChecked', 'onChange', 'onClick', 'statusLabelPosition'] + } + }, + decorators: [ + Story => { + return ( + + + + + + ) + } + ] +} as Meta + +export const Small = (args: Args) => ( + <> + + Notifications + + + +) + +export const WithCaption = (args: Args) => ( + + + + Notifications + + + Notifications will be delivered via email and the GitHub notification center + + + + +) +WithCaption.storyName = 'Associated with a caption' diff --git a/src/theme-preval.js b/src/theme-preval.js index 2f3ce2b7517..7f2222dd0d5 100644 --- a/src/theme-preval.js +++ b/src/theme-preval.js @@ -1,6 +1,6 @@ // @preval // This file needs to be a JavaScript file using CommonJS to be compatible with preval -// Cache bust: 2022-03-14 12:00:00 GMT (This file is cached by our deployment tooling, update this timestamp to rebuild this file) +// Cache bust: 2022-03-24 12:00:00 GMT (This file is cached by our deployment tooling, update this timestamp to rebuild this file) const {default: primitives} = require('@primer/primitives') const {partitionColors, fontStack, omitScale} = require('./utils/theme')