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/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/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/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/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)); 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/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 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..8d65f1c4b --- /dev/null +++ b/packages/components/src/switch/src/Switch.module.css @@ -0,0 +1,177 @@ +.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); + + /* 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-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..3f604dbcb --- /dev/null +++ b/packages/components/src/switch/src/index.ts @@ -0,0 +1,4 @@ +export * from "./Switch.tsx"; +export * from "./SwitchContext.ts"; +export * from "./SwitchField.tsx"; +export * from "./SwitchFieldContext.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(); + }); +});