Skip to content
This repository was archived by the owner on Jan 20, 2022. It is now read-only.

Commit

Permalink
✨ add Toggle component
Browse files Browse the repository at this point in the history
  • Loading branch information
justinanastos committed Aug 5, 2020
1 parent f2b4f7f commit 0a3ca47
Show file tree
Hide file tree
Showing 5 changed files with 360 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ tsconfig.tsbuildinfo
/SpaceKitProvider
/Table
/TextField
/Toggle
/Tooltip
/typography

Expand Down
2 changes: 2 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ function CJS() {
"@popperjs/core/lib/utils/getOppositePlacement",
"@popperjs/core/lib/utils/getOppositeVariationPlacement",
"@react-aria/checkbox",
"@react-aria/focus",
"@react-aria/switch",
"@react-aria/utils",
"@react-aria/visually-hidden",
"@react-stately/toggle",
Expand Down
140 changes: 140 additions & 0 deletions src/Toggle/Toggle.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { ToggleWrapper as Toggle } from "./toggle.story/ToggleWrapper";
import { Toggle as OriginalToggle } from "../Toggle";
import { colors } from "../colors";
import { Meta, Story, ArgsTable, Canvas } from "@storybook/addon-docs/blocks";
import noop from "lodash/noop";

<Meta title="Components/Toggle" component={OriginalToggle} />

# Toggle

**Toggles** use the same props schema as the underlying library from react-spectrum.

## Uncontrolled

Using the `defaultSelected` props to indicate these components are uncontrolled; meaning their state is not stored externally.

<Canvas>
<Story name="Uncontrolled Deselected">
<Toggle>Deslected</Toggle>
</Story>
<Story name="Uncontrolled Selected">
<Toggle defaultSelected>Selected</Toggle>
</Story>
<Story name="Uncontrolled Disabled Deselected">
<Toggle isDisabled>Disabled Deslected</Toggle>
</Story>
<Story name="Uncontrolled Disabled Selected">
<Toggle isDisabled defaultSelected>
Disabled Selected
</Toggle>
</Story>
</Canvas>

## Themed

### Midnight

<Canvas theme="midnight">
<Story name="Midnight Themed Uncontrolled Deselected">
<Toggle theme="midnight">Deslected</Toggle>
</Story>
<Story name="Midnight Themed Uncontrolled Selected">
<Toggle theme="midnight" defaultSelected>
Selected
</Toggle>
</Story>
<Story name="Midnight Themed Uncontrolled Disabled Deselected">
<Toggle theme="midnight" isDisabled>
Disabled Deslected
</Toggle>
</Story>
<Story name="Midnight Themed Uncontrolled Disabled Selected">
<Toggle theme="midnight" isDisabled defaultSelected>
Disabled Selected
</Toggle>
</Story>
</Canvas>

## Controlled

Pass `isSelectded` and `onChange` props to indicate these components are uncontrolled; meaning their state is not stored externally.

<Canvas>
<Story name="Controlled Deselected">
<Toggle isSelected={false} setSelected={noop}>
Deslected
</Toggle>
</Story>
<Story name="Controlled Selected">
<Toggle isSelected setSelected={noop}>
Selected
</Toggle>
</Story>
<Story name="Controlled Disabled Deselected">
<Toggle isDisabled isSelected={false} setSelected={noop}>
Disabled Deslected
</Toggle>
</Story>
<Story name="Controlled Disabled Selected">
<Toggle isDisabled isSelected setSelected={noop}>
Disabled Selected
</Toggle>
</Story>
</Canvas>

## Color

You can customize the color of the checkbox's selected state with the `color` prop

<Canvas>
<Story name="Color Deselected">
<Toggle color={colors.green.base}>Deslected</Toggle>
</Story>
<Story name="Color Selected">
<Toggle color={colors.green.base} defaultSelected>
Selected
</Toggle>
</Story>
<Story name="Color Disabled Deselected">
<Toggle color={colors.green.base} isDisabled>
Disabled Deslected
</Toggle>
</Story>
<Story name="Color Disabled Selected">
<Toggle color={colors.green.base} isDisabled defaultSelected>
Disabled Selected
</Toggle>
</Story>
</Canvas>

## Focus

Toggles will show a border when focused from keyboard navigation and not from touches or clicks.

<Canvas>
<Story name="Focus Deselected">
<Toggle isFocusVisible color={colors.green.base}>
Deslected
</Toggle>
</Story>
<Story name="Focus Selected">
<Toggle isFocusVisible color={colors.green.base} defaultSelected>
Selected
</Toggle>
</Story>
<Story name="Focus Disabled Deselected">
<Toggle isFocusVisible color={colors.green.base} isDisabled>
Disabled Deslected
</Toggle>
</Story>
<Story name="Focus Disabled Selected">
<Toggle isFocusVisible color={colors.green.base} isDisabled defaultSelected>
Disabled Selected
</Toggle>
</Story>
</Canvas>

## Props

<ArgsTable of="." />
179 changes: 179 additions & 0 deletions src/Toggle/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import React from "react";
import { ClassNames } from "@emotion/core";
import { getOffsetInPalette } from "../colors/utils/getOffsetInPalette";
import { ShadedColor, colors } from "../colors";
import { useFocusRing } from "@react-aria/focus";
import { useSwitch } from "@react-aria/switch";
import { useToggleState } from "@react-stately/toggle";
import { VisuallyHidden } from "@react-aria/visually-hidden";
import { assertUnreachable } from "../shared/assertUnreachable";
import { motion } from "framer-motion";

type ToggleProps = {
/**
* `className` to apply to the bounding `label`
*/
className?: string;
/**
* `style` to apply to the bounding `label`
*/
style?: React.CSSProperties;

/**
* Color to use for the checkbox itself. The check color and the border color
* will be automatically calculated.
*
* @default colors.blue.base
*/
color?: ShadedColor;

/**
* Force the focused styling
*
* This prop is typed as `never` so you can never legally pass it. This is
* intended only for testing because there's no other way to test a focus
* ring. The only place we're actually using this is in an `mdx` file, which
* doesn't check props with TypeScript.
*
* There's got to be a better way to do this; I just don't know what it is
* :shrug:
*/
isFocusVisible?: never;

/**
* Show the "ON" or "OFF" textual state
*
* @default `true`
*/
showTextualState?: boolean;

theme?: "light" | "midnight";
} & Parameters<typeof useSwitch>[0] &
Parameters<typeof useFocusRing>[0];

export const Toggle: React.FC<ToggleProps> = ({
className,
style,
color = colors.blue.base,
isFocusVisible: isFocusVisibleFromProps,
showTextualState = true,
theme = "light",
...props
}) => {
const state = useToggleState(props);
const ref = React.useRef<HTMLInputElement | null>(null);
const { inputProps } = useSwitch(props, state, ref);
const {
isFocusVisible: isFocusVisibleFromFocusRing,
focusProps,
} = useFocusRing(props);

const isFocusVisible =
(!props.isDisabled && isFocusVisibleFromProps) ||
isFocusVisibleFromFocusRing;

return (
<ClassNames>
{({ css, cx }) => (
<label
className={cx(
className,
css({
alignItems: "flex-start",
color:
theme === "light"
? props.isDisabled
? colors.silver.darker
: undefined
: theme === "midnight"
? props.isDisabled
? colors.grey.dark
: undefined
: assertUnreachable(theme),
display: "flex",
})
)}
style={style}
>
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} />
</VisuallyHidden>

<div className={css({ flex: 1, fontWeight: 600 })}>
{props.children}
</div>

{showTextualState && (
<div
aria-hidden
className={css({
color:
theme === "light"
? props.isDisabled
? undefined
: state.isSelected
? undefined
: colors.grey.base
: theme === "midnight"
? props.isDisabled
? undefined
: state.isSelected
? colors.white
: colors.midnight.lighter
: assertUnreachable(theme),
fontWeight: state.isSelected ? 600 : undefined,
marginLeft: 12,
})}
>
{state.isSelected ? "ON" : "OFF"}
</div>
)}

<div
aria-hidden
className={css({
backgroundColor: state.isSelected
? getOffsetInPalette(props.isDisabled ? 2 : 0, "lighter", color)
: props.isDisabled
? colors.silver.dark
: colors.grey.light,
borderRadius: 8,
boxShadow: [
isFocusVisible && `0 0 0 2px ${colors.blue.lighter}`,
!props.isDisabled && "inset 0 0 1px 0 rgba(18, 21, 26, 0.4)",
]
// The generic attached to the filter return type will indicate
// to TypeScript that we're stripping out all non-strings,
// meaning the booleans.
.filter((value): value is string => !!value)
.join(", "),
cursor: !props.isDisabled ? "pointer" : undefined,
height: 16,
marginLeft: 8,
padding: 2,
marginTop: 3,
position: "relative",
width: 32,
})}
>
<motion.div
animate={{
x: state.isSelected ? 16 : 0,
}}
initial={false}
transition={{ duration: 0.5, type: "tween", ease: "easeOut" }}
className={css({
backgroundColor: colors.white,
borderRadius: "100%",
height: 12,
position: "absolute",
top: 2,
width: 12,
})}
/>
</div>
</label>
)}
</ClassNames>
);
};
38 changes: 38 additions & 0 deletions src/Toggle/toggle.story/ToggleWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";
import { Toggle } from "../../Toggle";
import { ClassNames } from "@emotion/core";
import { colors } from "../../colors";
import { mergeProps } from "@react-aria/utils";
import { assertUnreachable } from "../../shared/assertUnreachable";

export const ToggleWrapper: React.FC<React.ComponentProps<typeof Toggle>> = (
props
) => {
return (
<ClassNames>
{({ css, cx }) => (
<Toggle
{...mergeProps(props, {
className: cx(
props.className,
css({
paddingLeft: 12,
paddingRight: 12,
width: 220,
whiteSpace: "nowrap",
}),
!props.theme || props.theme === "light"
? null
: props.theme === "midnight"
? css({
backgroundColor: colors.midnight.darkest,
color: colors.white,
})
: assertUnreachable(props.theme)
),
})}
/>
)}
</ClassNames>
);
};

0 comments on commit 0a3ca47

Please sign in to comment.