From 4d5214cc39a38838007dbe32d394e73be13c304c Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Wed, 24 Apr 2024 14:35:55 -0400 Subject: [PATCH 1/6] Feat: [HOP-99] Switch component & Checkbox fixes --- .../tests/chromatic/Checkbox.stories.tsx | 232 +++++++++++------- .../tests/chromatic/CheckboxField.stories.tsx | 33 ++- .../src/switch/docs/Switch.stories.tsx | 106 ++++++++ .../src/switch/docs/SwitchField.stories.tsx | 89 +++++++ packages/components/src/switch/index.ts | 1 + .../src/switch/src/Switch.module.css | 192 +++++++++++++++ packages/components/src/switch/src/Switch.tsx | 112 +++++++++ .../src/switch/src/SwitchContext.ts | 8 + .../src/switch/src/SwitchField.module.css | 55 +++++ .../components/src/switch/src/SwitchField.tsx | 105 ++++++++ .../src/switch/src/SwitchFieldContext.ts | 8 + packages/components/src/switch/src/index.ts | 2 + .../switch/tests/chromatic/Switch.stories.tsx | 205 ++++++++++++++++ .../tests/chromatic/SwitchField.stories.tsx | 88 +++++++ .../src/switch/tests/jest/Switch.ssr.test.tsx | 17 ++ .../src/switch/tests/jest/Switch.test.tsx | 96 ++++++++ .../switch/tests/jest/SwitchField.test.tsx | 94 +++++++ 17 files changed, 1334 insertions(+), 109 deletions(-) create mode 100644 packages/components/src/switch/docs/Switch.stories.tsx create mode 100644 packages/components/src/switch/docs/SwitchField.stories.tsx create mode 100644 packages/components/src/switch/index.ts create mode 100644 packages/components/src/switch/src/Switch.module.css create mode 100644 packages/components/src/switch/src/Switch.tsx create mode 100644 packages/components/src/switch/src/SwitchContext.ts create mode 100644 packages/components/src/switch/src/SwitchField.module.css create mode 100644 packages/components/src/switch/src/SwitchField.tsx create mode 100644 packages/components/src/switch/src/SwitchFieldContext.ts create mode 100644 packages/components/src/switch/src/index.ts create mode 100644 packages/components/src/switch/tests/chromatic/Switch.stories.tsx create mode 100644 packages/components/src/switch/tests/chromatic/SwitchField.stories.tsx create mode 100644 packages/components/src/switch/tests/jest/Switch.ssr.test.tsx create mode 100644 packages/components/src/switch/tests/jest/Switch.test.tsx create mode 100644 packages/components/src/switch/tests/jest/SwitchField.test.tsx diff --git a/packages/components/src/checkbox/tests/chromatic/Checkbox.stories.tsx b/packages/components/src/checkbox/tests/chromatic/Checkbox.stories.tsx index ff8029578..438967f9e 100644 --- a/packages/components/src/checkbox/tests/chromatic/Checkbox.stories.tsx +++ b/packages/components/src/checkbox/tests/chromatic/Checkbox.stories.tsx @@ -3,7 +3,7 @@ import { Div } from "@hopper-ui/styled-system"; import type { Meta, StoryObj } from "@storybook/react"; import { IconList } from "../../../IconList/src/IconList.tsx"; -import { Inline, Stack, Flex } from "../../../layout/index.ts"; +import { Inline, Stack } from "../../../layout/index.ts"; import { Text } from "../../../Text/index.ts"; import { Checkbox } from "../../src/Checkbox.tsx"; @@ -18,29 +18,6 @@ export default meta; type Story = StoryObj; export const Unchecked: Story = { - play: async ({ canvasElement }) => { - const checkboxLabels = canvasElement.querySelectorAll("label"); - - checkboxLabels.forEach(checkboxLabel => { - const checkbox = checkboxLabel.querySelector("input[type='checkbox']"); - if (checkbox && checkbox.getAttribute("disabled") !== "") { // don't try and force states on a disabled input - if (checkbox.getAttribute("data-chromatic-force-press")) { - checkboxLabel.setAttribute("data-pressed", "true"); - checkbox.removeAttribute("data-chromatic-force-press"); - } - - if (checkbox.getAttribute("data-chromatic-force-focus")) { - checkboxLabel.setAttribute("data-focus-visible", "true"); - checkbox.removeAttribute("data-chromatic-force-focus"); - } - - if (checkbox.getAttribute("data-chromatic-force-hover")) { - checkboxLabel.setAttribute("data-hovered", "true"); - checkbox.removeAttribute("data-chromatic-force-hover"); - } - } - }); - }, render: props => (

Labeled

@@ -78,10 +55,10 @@ export const Unchecked: Story = { - + - + @@ -112,79 +89,29 @@ export const Unchecked: Story = { -

States

- - -

Focus Visible

- - - Option 1 - - - Option 2 - - -

Hovered

- - - Option 1 - - - Option 2 - - -

Focus Visible and Hovered

- - - Option 1 - - - Option 2 - - -
- -

Disabled & Focus Visible

- - - Option 1 - - - Option 2 - - -

Disabled & Hovered

- - - Option 1 - - - Option 2 - - -

Disabled & Focus Visible and Hovered

- - - Option 1 - - - Option 2 - - -
-

Overflow

- +
PA-99-N2 event and possible exoplanet in galaxy - - +
+
PA-99-N2 event and possible exoplanet in galaxy - +
+
+ PA-99-N2 event and possible exoplanet in galaxy +
+
+ + PA-99-N2 event and possible exoplanet in galaxy + + + + +

Zoom

@@ -212,3 +139,126 @@ export const Indeterminate: Story = { isIndeterminate: true } }; + +export const UncheckedStates: Story = { + play: async ({ canvasElement }) => { + const checkboxLabels = canvasElement.querySelectorAll("label"); + + checkboxLabels.forEach(checkboxLabel => { + const checkbox = checkboxLabel.querySelector("input[type='checkbox']"); + if (checkbox && checkbox.getAttribute("disabled") !== "") { // don't try and force states on a disabled input + if (checkbox.getAttribute("data-chromatic-force-press")) { + checkboxLabel.setAttribute("data-pressed", "true"); + checkbox.removeAttribute("data-chromatic-force-press"); + } + + if (checkbox.getAttribute("data-chromatic-force-focus")) { + checkboxLabel.setAttribute("data-focus-visible", "true"); + checkbox.removeAttribute("data-chromatic-force-focus"); + } + + if (checkbox.getAttribute("data-chromatic-force-hover")) { + checkboxLabel.setAttribute("data-hovered", "true"); + checkbox.removeAttribute("data-chromatic-force-hover"); + } + } + }); + }, + render: props => ( + +

Focus Visible

+ + + Option 1 + + + Option 2 + + +

Hovered

+ + + Option 1 + + + Option 2 + + +

Focus Visible & Hovered

+ + + Option 1 + + + Option 2 + + +

Disabled & Focus Visible

+ + + Option 1 + + + Option 2 + + +

Disabled & Hovered

+ + + Option 1 + + + Option 2 + + +

Disabled, Focus Visible & Hovered

+ + + Option 1 + + + Option 2 + + +
+ ) +}; + +export const CheckedStates: Story = { + ...UncheckedStates, + args: { + defaultSelected: true + } +}; + +export const IndeterminateStates: Story = { + ...UncheckedStates, + args: { + defaultSelected: true, + isIndeterminate: true + } +}; + +export const InvalidUncheckedStates: Story = { + ...UncheckedStates, + args: { + isInvalid: true + } +}; + +export const InvalidCheckedStates: Story = { + ...UncheckedStates, + args: { + isInvalid: true, + defaultSelected: true + } +}; + +export const InvalidIndeterminateStates: Story = { + ...UncheckedStates, + args: { + isInvalid: true, + defaultSelected: true, + isIndeterminate: true + } +}; diff --git a/packages/components/src/checkbox/tests/chromatic/CheckboxField.stories.tsx b/packages/components/src/checkbox/tests/chromatic/CheckboxField.stories.tsx index 3299c965f..e07b94085 100644 --- a/packages/components/src/checkbox/tests/chromatic/CheckboxField.stories.tsx +++ b/packages/components/src/checkbox/tests/chromatic/CheckboxField.stories.tsx @@ -52,25 +52,22 @@ export const Validation: Story = { ) }; -export const States: Story = { +export const Disabled: Story = { render: props => ( - -

Disabled

- - - - Option 1 - - Description - - - - Option 1 - - Description - - -
+ + + + Option 1 + + Description + + + + Option 1 + + Description + + ) }; diff --git a/packages/components/src/switch/docs/Switch.stories.tsx b/packages/components/src/switch/docs/Switch.stories.tsx new file mode 100644 index 000000000..31135abf9 --- /dev/null +++ b/packages/components/src/switch/docs/Switch.stories.tsx @@ -0,0 +1,106 @@ +import { SparklesIcon } from "@hopper-ui/icons"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { IconList } from "../../IconList/index.ts"; +import { Stack } from "../../layout/index.ts"; +import { Text } from "../../Text/index.ts"; +import { Switch } from "../src/Switch.tsx"; + +/** + * A Switch is a control that is used to quickly switch between two possible states. Switches are only used for these binary actions that occur immediately after the user “flips the switch.” They are commonly used for “on/off” switches. + * + * [View repository](https://github.com/gsoft-inc/wl-hopper/tree/main/packages/components/src/Switch/src) + * - + * [View package](https://www.npmjs.com/package/@hopper-ui/components) + * - + * View storybook TODO + */ +const meta = { + title: "Docs/Switch/Switch", + tags: ["autodocs"], + parameters: { + // Disables Chromatic's snapshotting on documentation stories + chromatic: { disableSnapshot: true } + }, + component: Switch +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * The default switch. + */ +export const Default = { + args: { + children: "Label" + } +} satisfies Story; + +/** + * A Switch can be selected. + */ +export const Selected = { + args: { + ...Default.args, + defaultSelected: true + } +} satisfies Story; + +/** + * A Switch can be rendered without a label. + */ +export const NoLabel = { + args: { + "aria-label": "Label" + } +} satisfies Story; + +/** + * A Switch can be disabled. + */ +export const Disabled = { + args: { + ...Default.args, + isDisabled: true + } +} satisfies Story; + +/** + * A Switch can be rendered with an icon or an icon list. + */ +export const Icon = { + render: () => { + return ( + + + Label + + + + Label + + + + + + + ); + } +} satisfies Story; + +/** + * A Switch can be rendered in different sizes. + */ + +export const Sizes = { + render: () => { + return ( + + Small + Medium + + ); + } +} satisfies Story; diff --git a/packages/components/src/switch/docs/SwitchField.stories.tsx b/packages/components/src/switch/docs/SwitchField.stories.tsx new file mode 100644 index 000000000..60b383f0c --- /dev/null +++ b/packages/components/src/switch/docs/SwitchField.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Stack } from "../../layout/index.ts"; +import { Text } from "../../Text/index.ts"; +import { Switch } from "../src/Switch.tsx"; +import { SwitchField } from "../src/SwitchField.tsx"; + +/** + * The SwitchField component is used to group a switch with a description. + * + * [View repository](https://github.com/gsoft-inc/wl-hopper/tree/main/packages/components/src/Switch/src) + * - + * [View package](https://www.npmjs.com/package/@hopper-ui/components) + * - + * View storybook TODO + */ +const meta = { + title: "Docs/Switch/SwitchField", + tags: ["autodocs"], + parameters: { + // Disables Chromatic's snapshotting on documentation stories + chromatic: { disableSnapshot: true } + }, + component: SwitchField +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * A default SwitchField. + */ +export const Default: Story = { + render: props => ( + + + Option 1 + + + ) +}; + +/** + * A SwitchField with a description. + */ +export const Description: Story = { + render: props => ( + + + Option 1 + + Description + + ) +}; + +/** + * A SwitchField can be rendered in a disabled state. + */ +export const Disabled: Story = { + ...Description, + args: { + isDisabled: true + } +}; + +/** + * A SwitchField can be rendered in two different sizes. + */ +export const Sizes: Story = { + render: props => ( + + + + Option 1 + + Description + + + + Option 1 + + Description + + + ) +}; + diff --git a/packages/components/src/switch/index.ts b/packages/components/src/switch/index.ts new file mode 100644 index 000000000..401c73ac2 --- /dev/null +++ b/packages/components/src/switch/index.ts @@ -0,0 +1 @@ +export * from "./src/index.ts"; diff --git a/packages/components/src/switch/src/Switch.module.css b/packages/components/src/switch/src/Switch.module.css new file mode 100644 index 000000000..84c0e23b2 --- /dev/null +++ b/packages/components/src/switch/src/Switch.module.css @@ -0,0 +1,192 @@ +.hop-Switch { + /* Default */ + --hop-Switch-border-size: 0.0625rem; + --hop-Switch-border-color: var(--hop-neutral-border); + --hop-Switch-border-radius: var(--hop-shape-rounded-lg); + --hop-Switch-background-color: var(--hop-neutral-surface); + --hop-Switch-thumb-color: var(--hop-neutral-icon); + --hop-Switch-text-color: var(--hop-neutral-text); + + /* Hover */ + --hop-Switch-border-color-hover: var(--hop-neutral-border-hover); + --hop-Switch-background-color-hover: var(--hop-neutral-surface-hover); + --hop-Switch-thumb-color-hover: var(--hop-neutral-icon-hover); + --hop-Switch-text-color-hover: var(--hop-neutral-text-hover); + + /* Active */ + + /* --hop-Switch-border-color-active: var(--hop-neutral-border-press); + --hop-Switch-background-color-active: var(--hop-neutral-surface-press); + --hop-Switch-thumb-color-active: var(--hop-neutral-icon-press); + --hop-Switch-text-color-active: var(--hop-neutral-text-press); */ + + /* Focus Visible */ + --hop-Switch-border-color-focus: var(--hop-neutral-border-hover); + --hop-Switch-background-color-focus: var(--hop-neutral-surface-hover); + --hop-Switch-thumb-color-focus: var(--hop-neutral-icon-hover); + --hop-Switch-focus-ring-color: var(--hop-primary-border-focus); + --hop-Switch-text-color-focus: var(--hop-neutral-text); + + /* Selected */ + --hop-Switch-border-color-selected: var(--hop-neutral-border-active); + --hop-Switch-background-color-selected: var(--hop-neutral-surface-active); + --hop-Switch-thumb-color-selected: var(--hop-neutral-icon-active); + --hop-Switch-text-color-selected: var(--hop-neutral-text); + + /* Disabled */ + --hop-Switch-border-color-disabled: var(--hop-neutral-border-disabled); + --hop-Switch-background-color-disabled: var(--hop-neutral-surface-disabled); + --hop-Switch-thumb-color-disabled: var(--hop-neutral-icon-disabled); + --hop-Switch-text-color-disabled: var(--hop-neutral-text-disabled); + + /* Medium */ + --hop-Switch-inline-size-md: 3rem; + --hop-Switch-block-size-md: 1.5rem; + --hop-Switch-thumb-size-md: 1rem; + --hop-Switch-inset-inline-start-md: var(--hop-space-inset-xs); + --hop-Switch-text-top-offset-md: calc((var(--block-size) - (var(--hop-body-md-line-height) * var(--hop-body-md-font-size))) / 2); + + /* Small */ + --hop-Switch-inline-size-sm: 2rem; + --hop-Switch-block-size-sm: 1rem; + --hop-Switch-thumb-size-sm: 0.75rem; + --hop-Switch-inset-inline-start-sm: calc(var(--hop-space-inset-xs) / 2); + --hop-Switch-text-top-offset-sm: calc((var(--block-size) - (var(--hop-body-sm-line-height) * var(--hop-body-sm-font-size))) / 2); + + /* Internal Variables */ + --border-size: var(--hop-Switch-border-size); + --border-color: var(--hop-Switch-border-color); + --border-radius: var(--hop-Switch-border-radius); + --background-color: var(--hop-Switch-background-color); + --column-gap: var(--hop-space-inline-sm); + --thumb-color: var(--hop-Switch-thumb-color); + --thumb-transform: translate(0, -50%); + --outline: none; + --cursor: pointer; + --text-color: var(--hop-Switch-text-color); + + cursor: var(--cursor); + + display: inline-flex; + column-gap: var(--column-gap); + align-items: center; + align-items: start; + justify-content: start; + + box-sizing: border-box; + inline-size: max-content; + max-inline-size: 100%; +} + +.hop-Switch--sm { + --inline-size: var(--hop-Switch-inline-size-sm); + --block-size: var(--hop-Switch-block-size-sm); + --thumb-size: var(--hop-Switch-thumb-size-sm); + --inset-inline-start: var(--hop-Switch-inset-inline-start-sm); + --text-top-offset: var(--hop-Switch-text-top-offset-sm); +} + +.hop-Switch--md { + --inline-size: var(--hop-Switch-inline-size-md); + --block-size: var(--hop-Switch-block-size-md); + --thumb-size: var(--hop-Switch-thumb-size-md); + --inset-inline-start: var(--hop-Switch-inset-inline-start-md); + --text-top-offset: var(--hop-Switch-text-top-offset-md); +} + +.hop-Switch[data-hovered] { + --border-color: var(--hop-Switch-border-color-hover); + --background-color: var(--hop-Switch-background-color-hover); + --thumb-color: var(--hop-Switch-thumb-color-hover); + --text-color: var(--hop-Switch-text-color-hover); +} + +/* +.hop-Switch[data-pressed] { + --border-color: var(--hop-Switch-border-color-active); + --background-color: var(--hop-Switch-background-color-active); + --thumb-color: var(--hop-Switch-thumb-color-active); + --text-color: var(--hop-Switch-text-color-active); +} */ + +.hop-Switch[data-focus-visible] { + --border-color: var(--hop-Switch-border-color-focus); + --background-color: var(--hop-Switch-background-color-focus); + --thumb-color: var(--hop-Switch-thumb-color-focus); + --outline: 0.125rem solid var(--hop-Switch-focus-ring-color); + --text-color: var(--hop-Switch-text-color-focus); +} + +.hop-Switch[data-selected] { + --border-color: var(--hop-Switch-border-color-selected); + --background-color: var(--hop-Switch-background-color-selected); + --thumb-color: var(--hop-Switch-thumb-color-selected); + --thumb-transform: translate(calc(var(--inline-size) - + var(--thumb-size, var(--hop-Switch-thumb-size-md)) - + (2 * var(--inset-inline-start, var(--hop-Switch-inset-inline-start-md))) - + (2 * var(--border-size))), -50%); + --text-color: var(--hop-Switch-text-color-selected); +} + +.hop-Switch[data-disabled] { + --border-color: var(--hop-Switch-border-color-disabled); + --background-color: var(--hop-Switch-background-color-disabled); + --thumb-color: var(--hop-Switch-thumb-color-disabled); + --text-color: var(--hop-Switch-text-color-disabled); + --cursor: not-allowed; +} + +.hop-Switch__indicator { + position: relative; + + flex: 0 0 auto; + + box-sizing: border-box; + inline-size: var(--inline-size, var(--hop-Switch-inline-size-md)); + block-size: var(--block-size, var(--hop-Switch-block-size-md)); + + background-color: var(--background-color); + border: var(--border-size) solid var(--border-color); + border-radius: var(--border-radius); + outline: var(--outline); + outline-offset: 0.125rem; + + transition: background var(--hop-easing-duration-2), border-color var(--hop-easing-duration-2); +} + +.hop-Switch__indicator::before { + content: ""; + + position: absolute; + inset-block-start: 50%; + inset-inline-start: var(--inset-inline-start); + transform: var(--thumb-transform); + + flex: 0 0 auto; + order: 1; + + box-sizing: border-box; + inline-size: var(--thumb-size); + block-size: var(--thumb-size); + + background-color: var(--thumb-color); + border-radius: var(--hop-shape-circle); + + transition: transform var(--hop-easing-duration-2), background var(--hop-easing-duration-2); + +} + +.hop-Switch__text { + flex: 0 1 auto; + order: 2; + + min-inline-size: 0; + margin-block-start: var(--text-top-offset, var(--hop-Switch-text-top-offset-md)); + + color: var(--text-color); +} + +.hop-Switch__icon-list, +.hop-Switch__icon { + order: 3; +} \ No newline at end of file diff --git a/packages/components/src/switch/src/Switch.tsx b/packages/components/src/switch/src/Switch.tsx new file mode 100644 index 000000000..929bc5f61 --- /dev/null +++ b/packages/components/src/switch/src/Switch.tsx @@ -0,0 +1,112 @@ +import { IconContext } from "@hopper-ui/icons"; +import { type StyledComponentProps, useStyledSystem, type ResponsiveProp, useResponsiveValue } from "@hopper-ui/styled-system"; +import { forwardRef, type ForwardedRef } from "react"; +import { useContextProps, Switch as RACSwitch, type SwitchProps as RACSwitchProps, composeRenderProps } from "react-aria-components"; + +import { IconListContext } from "../../IconList/index.ts"; +import { Text, TextContext } from "../../Text/index.ts"; +import { composeClassnameRenderProps, SlotProvider, cssModule, isTextOnlyChildren, ClearContainerSlots } from "../../utils/index.ts"; + +import { SwitchContext } from "./SwitchContext.ts"; + +import styles from "./Switch.module.css"; + +export const GlobalSwitchCssSelector = "hop-Switch"; + +// Won't be needed in next react-aria-components release: https://github.com/adobe/react-spectrum/pull/5850 +const DefaultSwitchSlot = "switch"; + +export interface SwitchProps extends StyledComponentProps { + /** + * A Switch can vary in size. + * @default "md" + */ + size?: ResponsiveProp<"sm" | "md">; +} + +function Switch(props:SwitchProps, ref: ForwardedRef) { + [props, ref] = useContextProps({ ...props, slot: props.slot || DefaultSwitchSlot }, ref, SwitchContext); + const { stylingProps, ...ownProps } = useStyledSystem(props); + const { + className, + children: childrenProp, + size: sizeProp = "md", + style: styleProp, + ...otherProps + } = ownProps; + + const size = useResponsiveValue(sizeProp) ?? "md"; + + const classNames = composeClassnameRenderProps( + className, + GlobalSwitchCssSelector, + cssModule( + styles, + "hop-Switch", + size + ), + stylingProps.className + ); + + const style = composeRenderProps(styleProp, prev => { + return { + ...stylingProps.style, + ...prev + }; + }); + + const children = composeRenderProps(childrenProp, prev => { + if (prev && isTextOnlyChildren(prev)) { + return {prev}; + } + + return prev; + }); + + return ( + + {switchProps => { + return ( + <> +
+ + + {children(switchProps)} + + + + ); + }} + + ); +} + +/** + * A switch is used to quickly switch between two possible states. They are commonly used for “on/off” switches. + * + * [View Documentation](TODO) + */ +const _Switch = forwardRef(Switch); +_Switch.displayName = "Switch"; + +export { _Switch as Switch }; diff --git a/packages/components/src/switch/src/SwitchContext.ts b/packages/components/src/switch/src/SwitchContext.ts new file mode 100644 index 000000000..f253e78b0 --- /dev/null +++ b/packages/components/src/switch/src/SwitchContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { ContextValue } from "react-aria-components"; + +import type { SwitchProps } from "./Switch.tsx"; + +export const SwitchContext = createContext>({}); + +SwitchContext.displayName = "SwitchContext"; diff --git a/packages/components/src/switch/src/SwitchField.module.css b/packages/components/src/switch/src/SwitchField.module.css new file mode 100644 index 000000000..3a01c9bd7 --- /dev/null +++ b/packages/components/src/switch/src/SwitchField.module.css @@ -0,0 +1,55 @@ +.hop-SwitchField { + /* Default */ + --hop-SwitchField-description-color: var(--hop-neutral-text-weak); + + /* Disabled */ + --hop-SwitchField-description-color-disabled: var(--hop-neutral-text-disabled); + + /* Small */ + --hop-SwitchField-sm-row-gap: var(--hop-space-stack-xs); + --hop-SwitchField-switch-sm-inline-size: 2rem; + --hop-SwitchField-description-sm-margin-inline-start: calc(var(--hop-SwitchField-switch-sm-inline-size) + var(--hop-space-inline-sm)); + + /* Medium */ + --hop-SwitchField-md-row-gap: var(--hop-space-stack-xs); + --hop-SwitchField-switch-md-inline-size: 3rem; + --hop-SwitchField-description-md-margin-inline-start: calc(var(--hop-SwitchField-switch-md-inline-size) + var(--hop-space-inline-sm)); + + /* Internal variable */ + --description-color: var(--hop-SwitchField-description-color); + + display: flex; + flex-direction: column; + row-gap: var(--row-gap, var(--hop-space-stack-sm)); + align-items: flex-start; + justify-content: flex-start; + + box-sizing: border-box; + inline-size: max-content; + max-inline-size: 100%; +} + +.hop-SwitchField[data-disabled] { + --description-color: var(--hop-SwitchField-description-color-disabled); +} + +.hop-SwitchField--sm { + --row-gap: var(--hop-SwitchField-sm-row-gap); + --description-margin-inline-start: var(--hop-SwitchField-description-sm-margin-inline-start); +} + +.hop-SwitchField--md { + --row-gap: var(--hop-SwitchField-md-row-gap); + --description-margin-inline-start: var(--hop-SwitchField-description-md-margin-inline-start); +} + +/* Description */ +.hop-SwitchField__description { + order: 2; + margin-inline-start: var(--description-margin-inline-start, var(--hop-SwitchField-description-md-margin-inline-start)); + color: var(--description-color); +} + +.hop-SwitchField__switch { + order: 1; +} diff --git a/packages/components/src/switch/src/SwitchField.tsx b/packages/components/src/switch/src/SwitchField.tsx new file mode 100644 index 000000000..169b85f18 --- /dev/null +++ b/packages/components/src/switch/src/SwitchField.tsx @@ -0,0 +1,105 @@ +import { type StyledSystemProps, useStyledSystem, type ResponsiveProp, useResponsiveValue } from "@hopper-ui/styled-system"; +import clsx from "clsx"; +import { forwardRef, type ForwardedRef, type CSSProperties } from "react"; +import { useId } from "react-aria"; +import { useContextProps } from "react-aria-components"; + +import { TextContext, type TextProps } from "../../Text/index.ts"; +import { SlotProvider, type SizeAdapter, cssModule, type BaseComponentProps } from "../../utils/index.ts"; + +import { SwitchContext } from "./SwitchContext.ts"; +import { SwitchFieldContext } from "./SwitchFieldContext.ts"; + +import styles from "./SwitchField.module.css"; + +export const GlobalSwitchFieldCssSelector = "hop-SwitchField"; + +const SwitchToDescriptionSizeAdapter: SizeAdapter = { + sm: "xs", + md: "sm" +}; + +export interface SwitchFieldProps extends StyledSystemProps, BaseComponentProps { + /** + * Whether the switch field is disabled. + */ + isDisabled?: boolean; + /** + * A switch field can vary in size. + * @default "md" + */ + size?: ResponsiveProp<"sm" | "md">; +} + +function SwitchField(props:SwitchFieldProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, SwitchFieldContext); + const { stylingProps, ...ownProps } = useStyledSystem(props); + const { + className, + children, + isDisabled, + size: sizeProp = "md", + style, + slot = "switchField", + ...otherProps + } = ownProps; + + const size = useResponsiveValue(sizeProp) ?? "md"; + + const classNames = clsx( + className, + GlobalSwitchFieldCssSelector, + cssModule( + styles, + "hop-SwitchField", + size + ), + stylingProps.className + ); + + const mergedStyles: CSSProperties = { + ...stylingProps.style, + ...style + }; + + const descriptionId = useId(); + + return ( + +
+ {children} +
+
+ ); +} + +/** + * The Switch Field component is a container for a switch and a description. + * + * [View Documentation](TODO) + */ +const _SwitchField = forwardRef(SwitchField); +_SwitchField.displayName = "SwitchField"; + +export { _SwitchField as SwitchField }; diff --git a/packages/components/src/switch/src/SwitchFieldContext.ts b/packages/components/src/switch/src/SwitchFieldContext.ts new file mode 100644 index 000000000..9ecfd535e --- /dev/null +++ b/packages/components/src/switch/src/SwitchFieldContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { ContextValue } from "react-aria-components"; + +import type { SwitchFieldProps } from "./SwitchField.tsx"; + +export const SwitchFieldContext = createContext>({}); + +SwitchFieldContext.displayName = "SwitchFieldContext"; diff --git a/packages/components/src/switch/src/index.ts b/packages/components/src/switch/src/index.ts new file mode 100644 index 000000000..4b891a67a --- /dev/null +++ b/packages/components/src/switch/src/index.ts @@ -0,0 +1,2 @@ +export * from "./Switch.tsx"; +export * from "./SwitchContext.ts"; diff --git a/packages/components/src/switch/tests/chromatic/Switch.stories.tsx b/packages/components/src/switch/tests/chromatic/Switch.stories.tsx new file mode 100644 index 000000000..f2fa1c5dc --- /dev/null +++ b/packages/components/src/switch/tests/chromatic/Switch.stories.tsx @@ -0,0 +1,205 @@ +import { SparklesIcon } from "@hopper-ui/icons"; +import { Div } from "@hopper-ui/styled-system"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { IconList } from "../../../IconList/src/IconList.tsx"; +import { Inline, Stack } from "../../../layout/index.ts"; +import { Text } from "../../../Text/index.ts"; +import { Switch } from "../../src/Switch.tsx"; + + +const meta = { + title: "Components/Switch/Switch", + component: Switch +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Unchecked: Story = { + render: props => ( + +

Labeled

+ + + Option 1 + + + Option 2 + + + + + Option 1 + + + + Option 2 + + + + + + Option 1 + + + + Option 2 + + + +

Unlabeled

+ + + + + + + + + + + + + + + + + + + + +

Overflow

+
+ PA-99-N2 event and possible exoplanet in galaxy +
+
+ + PA-99-N2 event and possible exoplanet in galaxy + + + + +
+
+ PA-99-N2 event and possible exoplanet in galaxy +
+
+ + PA-99-N2 event and possible exoplanet in galaxy + + + + +
+

Zoom

+ +
+ Option 1 +
+
+ Option 2 +
+
+
+ ) +}; + +export const Checked: Story = { + ...Unchecked, + args: { + defaultSelected: true + } +}; + +export const UncheckedStates: Story = { + play: async ({ canvasElement }) => { + const switchLabels = canvasElement.querySelectorAll("label"); + + switchLabels.forEach(switchLabel => { + const switchElem = switchLabel.querySelector("input[type='checkbox']"); + if (switchElem && switchElem.getAttribute("disabled") !== "") { // don't try and force states on a disabled input + if (switchElem.getAttribute("data-chromatic-force-press")) { + switchLabel.setAttribute("data-pressed", "true"); + switchElem.removeAttribute("data-chromatic-force-press"); + } + + if (switchElem.getAttribute("data-chromatic-force-focus")) { + switchLabel.setAttribute("data-focus-visible", "true"); + switchElem.removeAttribute("data-chromatic-force-focus"); + } + + if (switchElem.getAttribute("data-chromatic-force-hover")) { + switchLabel.setAttribute("data-hovered", "true"); + switchElem.removeAttribute("data-chromatic-force-hover"); + } + } + }); + }, + render: props => ( + +

Focus Visible

+ + + Option 1 + + + Option 2 + + +

Hovered

+ + + Option 1 + + + Option 2 + + +

Focus Visible & Hovered

+ + + Option 1 + + + Option 2 + + +

Disabled & Focus Visible

+ + + Option 1 + + + Option 2 + + +

Disabled & Hovered

+ + + Option 1 + + + Option 2 + + +

Disabled, Focus Visible & Hovered

+ + + Option 1 + + + Option 2 + + +
+ ) +}; + +export const CheckedStates: Story = { + ...UncheckedStates, + args: { + defaultSelected: true + } +}; \ No newline at end of file diff --git a/packages/components/src/switch/tests/chromatic/SwitchField.stories.tsx b/packages/components/src/switch/tests/chromatic/SwitchField.stories.tsx new file mode 100644 index 000000000..9c7c1c424 --- /dev/null +++ b/packages/components/src/switch/tests/chromatic/SwitchField.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Inline, Stack } from "../../../layout/index.ts"; +import { Text } from "../../../Text/index.ts"; +import { Switch } from "../../src/Switch.tsx"; +import { SwitchField } from "../../src/SwitchField.tsx"; + +const meta = { + title: "Components/Switch/SwitchField", + component: SwitchField +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: props => ( + + + + Option 1 + + Description + + + + Option 1 + + Description + + + ) +}; + +export const Disabled: Story = { + render: props => ( + + + + Option 1 + + Description + + + + Option 1 + + Description + + + ) +}; + +export const Zoom: Story = { + render: props => ( + + + + + Option 1 + + Description + + + + Option 1 + + Description + + + + + + Option 1 + + Description + + + + Option 1 + + Description + + + + ) +}; diff --git a/packages/components/src/switch/tests/jest/Switch.ssr.test.tsx b/packages/components/src/switch/tests/jest/Switch.ssr.test.tsx new file mode 100644 index 000000000..8b7959154 --- /dev/null +++ b/packages/components/src/switch/tests/jest/Switch.ssr.test.tsx @@ -0,0 +1,17 @@ +/** + * @jest-environment node + */ +import { renderToString } from "react-dom/server"; + +import { Switch } from "../../src/Switch.tsx"; + +describe("Switch", () => { + it("should render on the server", () => { + const renderOnServer = () => + renderToString( + Text + ); + + expect(renderOnServer).not.toThrow(); + }); +}); diff --git a/packages/components/src/switch/tests/jest/Switch.test.tsx b/packages/components/src/switch/tests/jest/Switch.test.tsx new file mode 100644 index 000000000..a8c0e4a59 --- /dev/null +++ b/packages/components/src/switch/tests/jest/Switch.test.tsx @@ -0,0 +1,96 @@ +/* eslint-disable testing-library/no-node-access */ +/* Using closest to get the label is the best way, even react-aria does this. */ +import { act, screen, waitFor, render } from "@hopper-ui/test-utils"; +import { userEvent } from "@testing-library/user-event"; +import { createRef } from "react"; + +import { Switch } from "../../src/Switch.tsx"; +import { SwitchContext } from "../../src/SwitchContext.ts"; + + +describe("Switch", () => { + it("should render with default class", () => { + render(option 1); + + const element = screen.getByRole("switch").closest("label"); + expect(element).toHaveClass("hop-Switch"); + }); + + it("should support custom class", () => { + render(option 1); + + const element = screen.getByRole("switch").closest("label"); + expect(element).toHaveClass("hop-Switch"); + expect(element).toHaveClass("test"); + }); + + it("should support custom style", () => { + render(option 1); + + const element = screen.getByRole("switch").closest("label"); + expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" }); + }); + + it("should support DOM props", () => { + render(option 1); + + const element = screen.getByRole("switch").closest("label"); + expect(element).toHaveAttribute("data-foo", "bar"); + }); + + it("should support slots", () => { + render( + + option 1 + + ); + + const switchElem = screen.getByRole("switch"); + const element = switchElem.closest("label"); + + expect(element).toHaveAttribute("slot", "test"); + expect(switchElem).toHaveAttribute("aria-label", "test"); + }); + + it("should support refs", () => { + const ref = createRef(); + render(option 1); + + expect(ref.current).not.toBeNull(); + expect(ref.current instanceof HTMLLabelElement).toBeTruthy(); + }); + + it("should prevent onChange when the switch is disabled", async () => { + const handler = jest.fn(); + const user = userEvent.setup(); + + render( + + Disabled button + + ); + + const element = screen.getByRole("switch"); + await user.click(element); + + expect(handler).not.toHaveBeenCalled(); + }); + + // ***** Api ***** + + it("should be focused on render when the focus api is called", async () => { + const ref = createRef(); + + render(option 1); + const switchElement = ref.current?.querySelector("input[type='checkbox']") as HTMLInputElement; + + act(() => { + switchElement?.focus(); + }); + + await waitFor(() => expect(switchElement).toHaveFocus()); + }); +}); diff --git a/packages/components/src/switch/tests/jest/SwitchField.test.tsx b/packages/components/src/switch/tests/jest/SwitchField.test.tsx new file mode 100644 index 000000000..13770b5b9 --- /dev/null +++ b/packages/components/src/switch/tests/jest/SwitchField.test.tsx @@ -0,0 +1,94 @@ +/* eslint-disable testing-library/no-node-access */ +/* Using closest to get the label is the best way, even react-aria does this. */ +import { screen, render } from "@hopper-ui/test-utils"; +import { createRef } from "react"; + +import { Text } from "../../../Text/src/Text.tsx"; +import { Switch } from "../../src/Switch.tsx"; +import { SwitchField } from "../../src/SwitchField.tsx"; +import { SwitchFieldContext } from "../../src/SwitchFieldContext.ts"; + + +describe("Switch", () => { + const testId = "switch-field"; + + it("should render with default class", () => { + render(option 1description); + + const element = screen.getByTestId(testId); + expect(element).toHaveClass("hop-SwitchField"); + }); + + it("should support custom class", () => { + render(option 1description); + + const element = screen.getByTestId(testId); + expect(element).toHaveClass("hop-SwitchField"); + expect(element).toHaveClass("test"); + }); + + it("should support custom style", () => { + render(option 1description); + + const element = screen.getByTestId(testId); + expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" }); + }); + + it("should support DOM props", () => { + render(option 1description); + + const element = screen.getByTestId(testId); + expect(element).toHaveAttribute("data-foo", "bar"); + }); + + it("should support slots", () => { + render( + + option 1description + + ); + + const element = screen.getByTestId(testId); + + expect(element).toHaveAttribute("slot", "test"); + expect(element).toHaveAttribute("aria-label", "test"); + }); + + it("should support refs", () => { + const ref = createRef(); + render(option 1description); + + expect(ref.current).not.toBeNull(); + expect(ref.current instanceof HTMLDivElement).toBeTruthy(); + }); + + it("should set the size class name and pass the size to the switch", () => { + render(option 1description); + + const element = screen.getByTestId(testId); + const switchElem = screen.getByRole("switch").closest("label"); + expect(element).toHaveClass("hop-SwitchField--sm"); + expect(switchElem).toHaveClass("hop-Switch--sm"); + }); + + it("should set an id on the description and aria-describedby on the switch", () => { + render(option 1description); + + const switchElem = screen.getByRole("switch"); + const descriptionElement = screen.getByText("description"); + + expect(descriptionElement).toHaveAttribute("id"); + const descriptionId = descriptionElement.getAttribute("id"); + expect(switchElem).toHaveAttribute("aria-describedby", descriptionId); + }); + + it("should be disabled and pass it to the switch", () => { + render(option 1description); + + const element = screen.getByTestId(testId); + const switchElem = screen.getByRole("switch"); + + expect(element).toHaveAttribute("data-disabled", "true"); + expect(switchElem).toBeDisabled(); + }); +}); From 39b3cfee0d6bc74c535cf41bde4806f9dd9fa3c0 Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Wed, 24 Apr 2024 15:20:08 -0400 Subject: [PATCH 2/6] Fixed migration notes --- packages/components/src/checkbox/docs/migration-notes.md | 5 ++--- packages/components/src/switch/docs/migration-notes.md | 8 ++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 packages/components/src/switch/docs/migration-notes.md diff --git a/packages/components/src/checkbox/docs/migration-notes.md b/packages/components/src/checkbox/docs/migration-notes.md index 9c3344d8b..a6b8545ee 100644 --- a/packages/components/src/checkbox/docs/migration-notes.md +++ b/packages/components/src/checkbox/docs/migration-notes.md @@ -2,19 +2,18 @@ - The `counter` component is no longer allowed as a specialized slot. - `reverse` is not currently supported. - Values are not auto-generated when missed. -- `onChange` is only on RadioGroup, not Radio. +- `onChange` signature changed (no event passed). - `onValueChange` deleted, use onChange instead. - `checked` renamed to `isSelected`. - `disabled` renamed to `isDisabled`. - `required` renamed to `isRequired`. - `validationState` is removed. use `isInvalid` instead. There is no `isValid`. -- `value` is required. - `defaultIndeterminate` prop removed. - `indeterminate` renamed to `isIndeterminate`. # CheckboxGroup - `reverse` is not currently supported. -- `autofocus` is not supported. You must put `autofocus` on the actual Radio. +- `autofocus` is not supported. You must put `autofocus` on the actual Checkbox. - `onChange` signature changed (no event passed). - `disabled` renamed to `isDisabled`. - `required` renamed to `isRequired`. diff --git a/packages/components/src/switch/docs/migration-notes.md b/packages/components/src/switch/docs/migration-notes.md new file mode 100644 index 000000000..06de1b03e --- /dev/null +++ b/packages/components/src/switch/docs/migration-notes.md @@ -0,0 +1,8 @@ +# Switch +- The `counter` component is no longer allowed as a specialized slot. +- `reverse` is not currently supported. +- `onChange` signature changed (no event passed). +- `onValueChange` deleted, use onChange instead. +- `checked` renamed to `isSelected`. +- `disabled` renamed to `isDisabled`. +- `validationState` is removed. A switch cannot be invalid. \ No newline at end of file From ab658b8a309acfd520cf7a9ac8fb8cb3e02416fd Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Thu, 25 Apr 2024 09:27:06 -0400 Subject: [PATCH 3/6] Updated index files --- packages/components/src/index.ts | 1 + packages/components/src/switch/src/index.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 8e4615a57..3bf3cbf3d 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -9,6 +9,7 @@ export * from "./Label/index.ts"; export * from "./Link/index.ts"; export * from "./radio/index.ts"; export * from "./Spinner/index.ts"; +export * from "./switch/index.ts"; export * from "./Text/index.ts"; export * from "./utils/index.ts"; diff --git a/packages/components/src/switch/src/index.ts b/packages/components/src/switch/src/index.ts index 4b891a67a..5753d34dc 100644 --- a/packages/components/src/switch/src/index.ts +++ b/packages/components/src/switch/src/index.ts @@ -1,2 +1,4 @@ export * from "./Switch.tsx"; export * from "./SwitchContext.ts"; +export * from "./SwitchField.tsx"; +export * from "./SwitchFieldContext.tsx"; From c23b6a19c635e3da41dc1d5a8addbbd508143ef3 Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Thu, 25 Apr 2024 09:28:42 -0400 Subject: [PATCH 4/6] fixed extension --- packages/components/src/switch/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/switch/src/index.ts b/packages/components/src/switch/src/index.ts index 5753d34dc..3f604dbcb 100644 --- a/packages/components/src/switch/src/index.ts +++ b/packages/components/src/switch/src/index.ts @@ -1,4 +1,4 @@ export * from "./Switch.tsx"; export * from "./SwitchContext.ts"; export * from "./SwitchField.tsx"; -export * from "./SwitchFieldContext.tsx"; +export * from "./SwitchFieldContext.ts"; From 14676dc0beeff1ff08ec7e3e417fdaff8c076d90 Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Thu, 25 Apr 2024 15:24:29 -0400 Subject: [PATCH 5/6] Fixed gaps --- packages/components/src/checkbox/src/CheckboxField.module.css | 2 +- packages/components/src/radio/src/RadioField.module.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/checkbox/src/CheckboxField.module.css b/packages/components/src/checkbox/src/CheckboxField.module.css index 8e9267a04..8f33b2f2d 100644 --- a/packages/components/src/checkbox/src/CheckboxField.module.css +++ b/packages/components/src/checkbox/src/CheckboxField.module.css @@ -11,7 +11,7 @@ --hop-CheckboxField-description-sm-margin-inline-start: calc(var(--hop-CheckboxField-checkbox-sm-inline-size) + var(--hop-space-inline-sm)); /* Medium */ - --hop-CheckboxField-md-row-gap: var(--hop-space-stack-sm); + --hop-CheckboxField-md-row-gap: var(--hop-space-stack-xs); --hop-CheckboxField-checkbox-md-inline-size: 1.5rem; --hop-CheckboxField-description-md-margin-inline-start: calc(var(--hop-CheckboxField-checkbox-md-inline-size) + var(--hop-space-inline-md)); diff --git a/packages/components/src/radio/src/RadioField.module.css b/packages/components/src/radio/src/RadioField.module.css index 581e272bc..0283b8438 100644 --- a/packages/components/src/radio/src/RadioField.module.css +++ b/packages/components/src/radio/src/RadioField.module.css @@ -11,7 +11,7 @@ --hop-RadioField-description-sm-margin-inline-start: calc(var(--hop-RadioField-radio-sm-inline-size) + var(--hop-space-inline-sm)); /* Medium */ - --hop-RadioField-md-row-gap: var(--hop-space-stack-sm); + --hop-RadioField-md-row-gap: var(--hop-space-stack-xs); --hop-RadioField-radio-md-inline-size: 1.5rem; --hop-RadioField-description-md-margin-inline-start: calc(var(--hop-RadioField-radio-md-inline-size) + var(--hop-space-inline-md)); From dc89c18db3afcd08b70488fb80a053b65c4ef5b9 Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Fri, 26 Apr 2024 14:14:26 -0400 Subject: [PATCH 6/6] removed comments in css --- .../components/src/switch/src/Switch.module.css | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/components/src/switch/src/Switch.module.css b/packages/components/src/switch/src/Switch.module.css index 84c0e23b2..8d65f1c4b 100644 --- a/packages/components/src/switch/src/Switch.module.css +++ b/packages/components/src/switch/src/Switch.module.css @@ -13,13 +13,6 @@ --hop-Switch-thumb-color-hover: var(--hop-neutral-icon-hover); --hop-Switch-text-color-hover: var(--hop-neutral-text-hover); - /* Active */ - - /* --hop-Switch-border-color-active: var(--hop-neutral-border-press); - --hop-Switch-background-color-active: var(--hop-neutral-surface-press); - --hop-Switch-thumb-color-active: var(--hop-neutral-icon-press); - --hop-Switch-text-color-active: var(--hop-neutral-text-press); */ - /* Focus Visible */ --hop-Switch-border-color-focus: var(--hop-neutral-border-hover); --hop-Switch-background-color-focus: var(--hop-neutral-surface-hover); @@ -101,14 +94,6 @@ --text-color: var(--hop-Switch-text-color-hover); } -/* -.hop-Switch[data-pressed] { - --border-color: var(--hop-Switch-border-color-active); - --background-color: var(--hop-Switch-background-color-active); - --thumb-color: var(--hop-Switch-thumb-color-active); - --text-color: var(--hop-Switch-text-color-active); -} */ - .hop-Switch[data-focus-visible] { --border-color: var(--hop-Switch-border-color-focus); --background-color: var(--hop-Switch-background-color-focus);