From 34bcb9c15d8454248ae79c5ee98fefeb8da1e256 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Mon, 6 Jan 2025 09:46:49 -0500 Subject: [PATCH 01/17] SegmentedControl --- .changeset/cold-ways-rule.md | 6 + .../componentExample/ComponentExample.tsx | 2 +- apps/docs/app/ui/layout/header/Header.tsx | 2 +- .../components/themeSwitch/ThemeSwitch.tsx | 4 +- .../components/buttons/SegmentedControl.mdx | 47 +++++ apps/docs/examples/Preview.ts | 18 ++ .../src/SegmentedControl/docs/controlled.tsx | 27 +++ .../src/SegmentedControl/docs/icon.tsx | 17 ++ .../src/SegmentedControl/docs/iconOnly.tsx | 15 ++ .../src/SegmentedControl/docs/preview.tsx | 12 ++ .../src/SegmentedControl/docs/selected.tsx | 12 ++ .../src/SegmentedControl/docs/size.tsx | 12 ++ .../components/src/SegmentedControl/index.ts | 1 + .../src/SegmentedControl.module.css | 43 +++++ .../SegmentedControl/src/SegmentedControl.tsx | 144 +++++++++++++++ .../src/SegmentedControlContext.ts | 8 + .../src/SegmentedControlItem.module.css | 64 +++++++ .../src/SegmentedControlItem.tsx | 97 +++++++++++ .../src/SegmentedControlItemContext.ts | 10 ++ .../src/SegmentedControl/src/index.ts | 3 + .../chromatic/SegmentedControl.stories.tsx | 164 ++++++++++++++++++ .../tests/jest/SegmentedControl.ssr.test.tsx | 19 ++ .../tests/jest/SegmentedControl.test.tsx | 90 ++++++++++ packages/components/src/index.ts | 1 + 24 files changed, 814 insertions(+), 4 deletions(-) create mode 100644 .changeset/cold-ways-rule.md create mode 100644 apps/docs/content/components/buttons/SegmentedControl.mdx create mode 100644 packages/components/src/SegmentedControl/docs/controlled.tsx create mode 100644 packages/components/src/SegmentedControl/docs/icon.tsx create mode 100644 packages/components/src/SegmentedControl/docs/iconOnly.tsx create mode 100644 packages/components/src/SegmentedControl/docs/preview.tsx create mode 100644 packages/components/src/SegmentedControl/docs/selected.tsx create mode 100644 packages/components/src/SegmentedControl/docs/size.tsx create mode 100644 packages/components/src/SegmentedControl/index.ts create mode 100644 packages/components/src/SegmentedControl/src/SegmentedControl.module.css create mode 100644 packages/components/src/SegmentedControl/src/SegmentedControl.tsx create mode 100644 packages/components/src/SegmentedControl/src/SegmentedControlContext.ts create mode 100644 packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css create mode 100644 packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx create mode 100644 packages/components/src/SegmentedControl/src/SegmentedControlItemContext.ts create mode 100644 packages/components/src/SegmentedControl/src/index.ts create mode 100644 packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx create mode 100644 packages/components/src/SegmentedControl/tests/jest/SegmentedControl.ssr.test.tsx create mode 100644 packages/components/src/SegmentedControl/tests/jest/SegmentedControl.test.tsx diff --git a/.changeset/cold-ways-rule.md b/.changeset/cold-ways-rule.md new file mode 100644 index 00000000..3e679da8 --- /dev/null +++ b/.changeset/cold-ways-rule.md @@ -0,0 +1,6 @@ +--- +"docs": patch +"@hopper-ui/components": patch +--- + +Added SegmentedControl component diff --git a/apps/docs/app/ui/components/componentExample/ComponentExample.tsx b/apps/docs/app/ui/components/componentExample/ComponentExample.tsx index 6c59dd9d..16970e7e 100644 --- a/apps/docs/app/ui/components/componentExample/ComponentExample.tsx +++ b/apps/docs/app/ui/components/componentExample/ComponentExample.tsx @@ -4,7 +4,7 @@ import clsx from "clsx"; import { memo, useEffect, useState, type ReactNode } from "react"; import { CodeIcon, Icon, StackblitzIcon } from "@/components/icon"; -import { ToggleButton } from "@/components/toggleButton/ToggleButton.tsx"; +import { ToggleButton } from "@/components/ToggleButton/ToggleButton.tsx"; import { useToggle } from "@/hooks/useToggle.ts"; import ComponentPreviewWrapper from "./ComponentPreviewWrapper.tsx"; diff --git a/apps/docs/app/ui/layout/header/Header.tsx b/apps/docs/app/ui/layout/header/Header.tsx index 89609556..b9cfbe21 100644 --- a/apps/docs/app/ui/layout/header/Header.tsx +++ b/apps/docs/app/ui/layout/header/Header.tsx @@ -12,7 +12,7 @@ import { Icon, ProductMenuIcon } from "@/components/icon"; import LinkIconButton from "@/components/iconButton/LinkIconButton"; import { Popover, PopoverContext, PopoverTrigger } from "@/components/popover/Popover.tsx"; import ThemeSwitch from "@/components/themeSwitch/ThemeSwitch"; -import { ToggleButton, ToggleButtonContext } from "@/components/toggleButton/ToggleButton.tsx"; +import { ToggleButton, ToggleButtonContext } from "@/components/ToggleButton/ToggleButton"; import { navigation } from "@/configs/navigation"; import { ThemeContext, type ColorScheme } from "@/context/theme/ThemeProvider.tsx"; import { useIsMobile } from "@/hooks/useIsMobile"; diff --git a/apps/docs/components/themeSwitch/ThemeSwitch.tsx b/apps/docs/components/themeSwitch/ThemeSwitch.tsx index 419c7a2a..650dc4a8 100644 --- a/apps/docs/components/themeSwitch/ThemeSwitch.tsx +++ b/apps/docs/components/themeSwitch/ThemeSwitch.tsx @@ -1,7 +1,7 @@ -import { ToggleButton } from "@/components/toggleButton/ToggleButton"; +import { ToggleButton } from "@/components/ToggleButton/ToggleButton"; -import type { ColorScheme } from "@/context/theme/ThemeProvider"; import Icon from "@/components/themeSwitch/ThemeSwitchIcons"; +import type { ColorScheme } from "@/context/theme/ThemeProvider"; import clsx from "clsx"; import "./themeSwitch.css"; diff --git a/apps/docs/content/components/buttons/SegmentedControl.mdx b/apps/docs/content/components/buttons/SegmentedControl.mdx new file mode 100644 index 00000000..c3ae2c27 --- /dev/null +++ b/apps/docs/content/components/buttons/SegmentedControl.mdx @@ -0,0 +1,47 @@ +--- +title: SegmentedControl +description: The SegmentedControl component presents a horizontal row of options or actions that are contextually or conceptually related. It allows users to select a single option at a time. +category: "pickers" +links: + source: https://github.com/gsoft-inc/wl-hopper/blob/main/packages/components/src/SegmentedControl/src/SegmentedControl.tsx + aria: https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/ +--- + +<Example src="SegmentedControl/docs/preview" isOpen /> + +## Usage + +### Selected +A segmented control supports single selection. At any given time, only one item can be active. + +<Example src="SegmentedControl/docs/selected" /> + +### Size +A segmented control supports multiple sizes. Here’s an example demonstrating the medium size option: + +<Example src="SegmentedControl/docs/size" /> + +### Icon only +Items within a segmented control can contain only icons. An accessible name must be provided through [aria-label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label) prop. See also [WCAG practices](https://www.w3.org/TR/WCAG20-TECHS/ARIA6.html). + +<Example src="SegmentedControl/docs/iconOnly" /> + +### Icon +A segmented control can contain items with icons, leading or trailing. + +<Example src="SegmentedControl/docs/icon" /> + +### Controlled +A segmented control can have a controlled selected value. In this example, it shows how it is possible to select an option. + +<Example src="SegmentedControl/docs/controlled" /> + +## Props + +### SegmentedControl + +<PropTable component="SegmentedControl" /> + +### SegmentedControlItem + +<PropTable component="SegmentedControlItem" /> diff --git a/apps/docs/examples/Preview.ts b/apps/docs/examples/Preview.ts index 403776f4..84af1001 100644 --- a/apps/docs/examples/Preview.ts +++ b/apps/docs/examples/Preview.ts @@ -95,6 +95,24 @@ export const Previews: Record<string, Preview> = { "buttons/docs/linkButton/advancedCustomization": { component: lazy(() => import("@/../../packages/components/src/buttons/docs/linkButton/advancedCustomization.tsx")) }, + "SegmentedControl/docs/preview": { + component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/preview.tsx")) + }, + "SegmentedControl/docs/selected": { + component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/selected.tsx")) + }, + "SegmentedControl/docs/size": { + component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/size.tsx")) + }, + "SegmentedControl/docs/iconOnly": { + component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/iconOnly.tsx")) + }, + "SegmentedControl/docs/icon": { + component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/icon.tsx")) + }, + "SegmentedControl/docs/controlled": { + component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/controlled.tsx")) + }, "ListBox/docs/preview": { component: lazy(() => import("@/../../packages/components/src/ListBox/docs/preview.tsx")) }, diff --git a/packages/components/src/SegmentedControl/docs/controlled.tsx b/packages/components/src/SegmentedControl/docs/controlled.tsx new file mode 100644 index 00000000..05303931 --- /dev/null +++ b/packages/components/src/SegmentedControl/docs/controlled.tsx @@ -0,0 +1,27 @@ +import { SegmentedControl, SegmentedControlItem, type Key } from "@hopper-ui/components"; +import { useState } from "react"; + +export default function Example() { + const [selectedKey, setSelectedKey] = useState<Key>(); + + const handleSelectionChange = (key: Key) => { + if (selectedKey === key) { + return; + } + + setSelectedKey(key); + }; + + return ( + <SegmentedControl + aria-label="Time granularity" + selectedKey={selectedKey} + onSelectionChange={handleSelectionChange} + > + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> + </SegmentedControl> + ); +} diff --git a/packages/components/src/SegmentedControl/docs/icon.tsx b/packages/components/src/SegmentedControl/docs/icon.tsx new file mode 100644 index 00000000..ee4b46a2 --- /dev/null +++ b/packages/components/src/SegmentedControl/docs/icon.tsx @@ -0,0 +1,17 @@ +import { SegmentedControl, SegmentedControlItem, Text } from "@hopper-ui/components"; +import { OrderedListIcon, UnorderedListIcon } from "@hopper-ui/icons"; + +export default function Example() { + return ( + <SegmentedControl aria-label="List ordering"> + <SegmentedControlItem id="unordered"> + <UnorderedListIcon /> + <Text>Unordered</Text> + </SegmentedControlItem> + <SegmentedControlItem id="ordered"> + <Text>Ordered</Text> + <OrderedListIcon /> + </SegmentedControlItem> + </SegmentedControl> + ); +} diff --git a/packages/components/src/SegmentedControl/docs/iconOnly.tsx b/packages/components/src/SegmentedControl/docs/iconOnly.tsx new file mode 100644 index 00000000..2dd3de56 --- /dev/null +++ b/packages/components/src/SegmentedControl/docs/iconOnly.tsx @@ -0,0 +1,15 @@ +import { SegmentedControl, SegmentedControlItem } from "@hopper-ui/components"; +import { OrderedListIcon, UnorderedListIcon } from "@hopper-ui/icons"; + +export default function Example() { + return ( + <SegmentedControl aria-label="List ordering"> + <SegmentedControlItem id="unordered"> + <UnorderedListIcon /> + </SegmentedControlItem> + <SegmentedControlItem id="ordered"> + <OrderedListIcon /> + </SegmentedControlItem> + </SegmentedControl> + ); +} diff --git a/packages/components/src/SegmentedControl/docs/preview.tsx b/packages/components/src/SegmentedControl/docs/preview.tsx new file mode 100644 index 00000000..ab2b4905 --- /dev/null +++ b/packages/components/src/SegmentedControl/docs/preview.tsx @@ -0,0 +1,12 @@ +import { SegmentedControl, SegmentedControlItem } from "@hopper-ui/components"; + +export default function Example() { + return ( + <SegmentedControl aria-label="Time granularity"> + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> + </SegmentedControl> + ); +} diff --git a/packages/components/src/SegmentedControl/docs/selected.tsx b/packages/components/src/SegmentedControl/docs/selected.tsx new file mode 100644 index 00000000..ccfa53bf --- /dev/null +++ b/packages/components/src/SegmentedControl/docs/selected.tsx @@ -0,0 +1,12 @@ +import { SegmentedControl, SegmentedControlItem } from "@hopper-ui/components"; + +export default function Example() { + return ( + <SegmentedControl aria-label="Time granularity" defaultSelectedKey="day"> + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> + </SegmentedControl> + ); +} diff --git a/packages/components/src/SegmentedControl/docs/size.tsx b/packages/components/src/SegmentedControl/docs/size.tsx new file mode 100644 index 00000000..51485dbc --- /dev/null +++ b/packages/components/src/SegmentedControl/docs/size.tsx @@ -0,0 +1,12 @@ +import { SegmentedControl, SegmentedControlItem } from "@hopper-ui/components"; + +export default function Example() { + return ( + <SegmentedControl aria-label="Time granularity" defaultSelectedKey="day" size="md"> + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> + </SegmentedControl> + ); +} diff --git a/packages/components/src/SegmentedControl/index.ts b/packages/components/src/SegmentedControl/index.ts new file mode 100644 index 00000000..401c73ac --- /dev/null +++ b/packages/components/src/SegmentedControl/index.ts @@ -0,0 +1 @@ +export * from "./src/index.ts"; diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.module.css b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css new file mode 100644 index 00000000..28a90d45 --- /dev/null +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css @@ -0,0 +1,43 @@ +.hop-SegmentedControl { + --hop-SegmentedControl-gap: var(--hop-space-inline-xs); + --hop-SegmentedControl-padding: var(--hop-space-inset-xs); + --hop-SegmentedControl-witdh: fit-content; + --hop-SegmentedControl-background: var(--hop-neutral-surface); + --hop-SegmentedControl-border: 0.0625rem solid var(--hop-neutral-border); + --hop-SegmentedControl-border-radius: var(--hop-shape-rounded-md); + + /* Item background selected animation */ + --hop-SegmentedControl-item-width: 0rem; + --hop-SegmentedControl-item-border-radius: var(--hop-shape-rounded-sm); + --hop-SegmentedControl-item-offset: 0rem; + + position: relative; + + display: flex; + gap: var(--hop-SegmentedControl-gap); + align-items: center; + justify-content: center; + + inline-size: var(--hop-SegmentedControl-witdh); + padding: var(--hop-SegmentedControl-padding); + + background: var(--hop-SegmentedControl-background); + border: var(--hop-SegmentedControl-border); + border-radius: var(--hop-SegmentedControl-border-radius) +} + +.hop-SegmentedControl::after { + content: ''; + + position: absolute; + inset-block-start: var(--hop-SegmentedControl-padding); + inset-inline-start: var(--hop-SegmentedControl-item-offset); + + inline-size: var(--hop-SegmentedControl-item-width); + block-size: calc(100% - 2 * var(--hop-SegmentedControl-padding)); + + background: var(--hop-neutral-surface-selected); + border-radius: var(--hop-SegmentedControl-item-border-radius); + + transition: left 0.3s; +} diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx new file mode 100644 index 00000000..dd24d807 --- /dev/null +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx @@ -0,0 +1,144 @@ +import { useStyledSystem, type ResponsiveProp, type StyledComponentProps } from "@hopper-ui/styled-system"; +import clsx from "clsx"; +import { forwardRef, useEffect, useMemo, useState, type CSSProperties, type ForwardedRef } from "react"; +import { Provider, ToggleButtonGroup, useContextProps, type Key } from "react-aria-components"; + +import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts"; + +import { SegmentedControlContext } from "./SegmentedControlContext.ts"; +import type { SegmentedControlItemSize } from "./SegmentedControlItem.tsx"; +import { SegmentedControlItemContext } from "./SegmentedControlItemContext.ts"; + +import styles from "./SegmentedControl.module.css"; + +export const GlobalSegmentedControlCssSelector = "hop-SegmentedControl"; + +export interface SegmentedControlProps extends StyledComponentProps<BaseComponentDOMProps> { + /** + * Whether the segmented control is disabled. + */ + isDisabled?: boolean; + /** + * The id of the currently selected item (controlled). + */ + selectedKey?: Key; + /** + * The id of the initial selected item (uncontrolled). + */ + defaultSelectedKey?: Key; + /** + * Handler that is called when the selection changes. + */ + onSelectionChange?: (id: Key) => void; + /** + * The size of the controls. + * * @default "sm" + */ + size?: ResponsiveProp<SegmentedControlItemSize>; +} + +const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDivElement>) => { + [props, ref] = useContextProps(props, ref, SegmentedControlContext); + + const { stylingProps, ...ownProps } = useStyledSystem(props); + const { + className, + children, + style, + slot, + defaultSelectedKey, + selectedKey, + onSelectionChange, + ...otherProps + } = ownProps; + + const [selected, setSelected] = useState<Key | undefined>(defaultSelectedKey ?? selectedKey); + + const classNames = clsx( + GlobalSegmentedControlCssSelector, + cssModule( + styles, + "hop-SegmentedControl" + ), + stylingProps.className, + className + ); + + const mergedStyles: CSSProperties = { + ...style, + ...stylingProps.style + }; + + const onChange = (values: Set<Key>) => { + const [firstKey] = values; + + if (!firstKey) { + return; + } + + setSelected(firstKey); + onSelectionChange?.(firstKey); + }; + + const selectedKeys = useMemo(() => { + if (onSelectionChange) { + return selectedKey != null ? [selectedKey] : undefined; + } + + return selected ? [selected] : undefined; + }, [onSelectionChange, selectedKey, selected]); + + useEffect(() => { + const container = ref.current; + + // Code to create sliding animation background between buttons when selecting a new value + if (container && (selectedKeys || defaultSelectedKey)) { + const { childNodes } = container; + + const childNodesArray = Array.from(childNodes) as HTMLElement[]; + + const [firstKey] = selectedKeys ?? []; + const key = firstKey ?? defaultSelectedKey; + const selectedNode = childNodesArray.find(x => x.getAttribute("data-key") === key); + + if (!selectedNode) { + return; + } + + container.style.setProperty("--hop-SegmentedControl-item-width", `${selectedNode.offsetWidth}px`); + container.style.setProperty("--hop-SegmentedControl-item-offset", `${selectedNode.offsetLeft}px`); + } + }, [ref, selectedKeys, defaultSelectedKey]); + + return ( + <ToggleButtonGroup + ref={ref} + className={classNames} + style={mergedStyles} + slot={slot ?? undefined} + selectedKeys={selectedKeys} + defaultSelectedKeys={defaultSelectedKey != null ? [defaultSelectedKey] : undefined} + orientation="horizontal" + onSelectionChange={onChange} + {...otherProps} + > + <Provider + values={[ + [SegmentedControlItemContext, otherProps] + ]} + > + {children} + </Provider> + </ToggleButtonGroup> + ); +}; + +/** + * Segmented control displays multiple contextually or conceptually related action or option stacked in a horizontal row. + * + * [View Documentation](TODO) + */ +const _SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(SegmentedControl); +_SegmentedControl.displayName = "SegmentedControl"; + +export { _SegmentedControl as SegmentedControl }; diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts b/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts new file mode 100644 index 00000000..b5397cf0 --- /dev/null +++ b/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { ContextValue } from "react-aria-components"; + +import type { SegmentedControlProps } from "./SegmentedControl.tsx"; + +export const SegmentedControlContext = createContext<ContextValue<SegmentedControlProps, HTMLDivElement>>({}); + +SegmentedControlContext.displayName = "SegmentedControlContext"; diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css new file mode 100644 index 00000000..d0ac2918 --- /dev/null +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css @@ -0,0 +1,64 @@ +.hop-SegmentedControlItem { + /* Default */ + --hop-SegmentedControlItem-gap: var(--hop-space-inline-xs); + --hop-SegmentedControlItem-color: var(--hop-neutral-text); + --hop-SegmentedControlItem-background: transparent; + --hop-SegmentedControlItem-border: none; + --hop-SegmentedControlItem-border-radius: var(--hop-shape-rounded-sm); + --hop-SegmentedControlItem-cursor: auto; + --hop-SegmentedControlItem-z-index: 1; + --hop-SegmentedControlItem-color-transition: color 0.3s; + + /* Small */ + --hop-SegmentedControlItem-sm-padding: 0.125rem var(--hop-space-inset-md); + + /* Medium */ + --hop-SegmentedControlItem-md-padding: 0.375rem var(--hop-space-inset-md); + + cursor: var(--hop-SegmentedControlItem-cursor); + + z-index: var(--hop-SegmentedControlItem-z-index); + + display: flex; + gap: var(--hop-SegmentedControlItem-gap); + align-items: center; + justify-content: center; + + padding: var(--hop-SegmentedControlItem-sm-padding); + + color: var(--hop-SegmentedControlItem-color); + + background: var(--hop-SegmentedControlItem-background); + border: var(--hop-SegmentedControlItem-border); + border-radius: var(--hop-SegmentedControlItem-border-radius); + + transition: var(--hop-SegmentedControlItem-color-transition); +} + +.hop-SegmentedControlItem:not([data-disabled]) { + --hop-SegmentedControlItem-cursor: pointer; +} + +.hop-SegmentedControlItem[data-hovered], .hop-SegmentedControlItem[data-focused] { + --hop-SegmentedControlItem-color: var(--hop-neutral-text-hover); + --hop-SegmentedControlItem-background: var(--hop-neutral-surface-hover); +} + +.hop-SegmentedControlItem[data-pressed] { + --hop-SegmentedControlItem-color: var(--hop-neutral-text-press); + --hop-SegmentedControlItem-background: var(--hop-neutral-surface-press); +} + +.hop-SegmentedControlItem[data-selected] { + --hop-SegmentedControlItem-color: var(--hop-neutral-text-selected); + --hop-SegmentedControlItem-background: transparent; +} + +.hop-SegmentedControlItem[data-disabled] { + --hop-SegmentedControlItem-color: var(--hop-neutral-text-disabled); + --hop-SegmentedControlItem-background: var(--hop-neutral-surface); +} + +.hop-SegmentedControlItem--md { + --hop-SegmentedControlItem-padding: var(--hop-SegmentedControlItem-md-padding); +} diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx new file mode 100644 index 00000000..5674b683 --- /dev/null +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -0,0 +1,97 @@ +import { IconContext } from "@hopper-ui/icons"; +import { useStyledSystem, type ResponsiveProp, type StyledComponentProps } from "@hopper-ui/styled-system"; +import clsx from "clsx"; +import { forwardRef, type CSSProperties, type ForwardedRef } from "react"; +import { Provider, ToggleButton, useContextProps, type Key } from "react-aria-components"; + +import { Text, TextContext } from "../../typography/index.ts"; +import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts"; + +import { SegmentedControlItemContext } from "./SegmentedControlItemContext.ts"; + +import styles from "./SegmentedControlItem.module.css"; + +export const GlobalSegmentedControlItemCssSelector = "hop-SegmentedControlItem"; + +export type SegmentedControlItemSize = "sm" | "md"; + +export interface SegmentedControlItemProps extends Omit<StyledComponentProps<BaseComponentDOMProps>, "id"> { + /** + * The id of the item, matching the value used in SegmentedControl's `selectedKey` prop. + */ + id: Key; + /** + * Whether the item is disabled or not. + */ + isDisabled?: boolean; + /** + * The size of the item. + * * @default "sm" + */ + size?: ResponsiveProp<SegmentedControlItemSize>; +} + +const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRef<HTMLButtonElement>) => { + [props, ref] = useContextProps(props, ref, SegmentedControlItemContext); + + const { stylingProps, ...ownProps } = useStyledSystem(props); + const { + className, + children, + style, + slot, + size = "sm", + ...otherProps + } = ownProps; + + const classNames = clsx( + GlobalSegmentedControlItemCssSelector, + cssModule( + styles, + "hop-SegmentedControlItem", + size + ), + stylingProps.className, + className + ); + + const mergedStyles: CSSProperties = { + ...style, + ...stylingProps.style + }; + + return ( + <ToggleButton + {...otherProps} + ref={ref} + className={classNames} + style={mergedStyles} + slot={slot ?? undefined} + data-key={props.id} + > + <Provider + values={[ + [IconContext, { + size + }], + [TextContext, { + size: "sm" + }] + ]} + > + {typeof children === "string" ? <Text>{children}</Text> : children} + </Provider> + </ToggleButton> + ); +}; + +/** + * A SegmentedControlItem represents an option within a SegmentedControl. + * + * [View Documentation](TODO) + */ +const _SegmentedControlItem = forwardRef<HTMLButtonElement, SegmentedControlItemProps>(SegmentedControlItem); +_SegmentedControlItem.displayName = "SegmentedControlItem"; + +export { _SegmentedControlItem as SegmentedControlItem }; + diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItemContext.ts b/packages/components/src/SegmentedControl/src/SegmentedControlItemContext.ts new file mode 100644 index 00000000..3a83be78 --- /dev/null +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItemContext.ts @@ -0,0 +1,10 @@ +import { createContext } from "react"; +import type { ContextValue } from "react-aria-components"; + +import type { SegmentedControlItemProps } from "./SegmentedControlItem.tsx"; + +interface InternalSegmentedControlItemProps extends Omit<SegmentedControlItemProps, "id"> {} + +export const SegmentedControlItemContext = createContext<ContextValue<InternalSegmentedControlItemProps, HTMLButtonElement>>({}); + +SegmentedControlItemContext.displayName = "SegmentedControlItemContext"; diff --git a/packages/components/src/SegmentedControl/src/index.ts b/packages/components/src/SegmentedControl/src/index.ts new file mode 100644 index 00000000..299fbc8a --- /dev/null +++ b/packages/components/src/SegmentedControl/src/index.ts @@ -0,0 +1,3 @@ +export * from "./SegmentedControl.tsx"; +export * from "./SegmentedControlItem.tsx"; + diff --git a/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx b/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx new file mode 100644 index 00000000..950eeab9 --- /dev/null +++ b/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx @@ -0,0 +1,164 @@ +import { OrderedListIcon, UnorderedListIcon } from "@hopper-ui/icons"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { Inline, Stack } from "../../../layout/index.ts"; +import { Text } from "../../../typography/Text/index.ts"; +import { SegmentedControl, SegmentedControlItem } from "../../src/index.ts"; + +const meta = { + title: "Components/SegmentedControl", + component: SegmentedControl +} satisfies Meta<typeof SegmentedControl>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Default = { + render: props => ( + <SegmentedControl defaultSelectedKey="day" {...props}> + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> + </SegmentedControl> + ), + args: { + "aria-label": "Time granularity" + } +} satisfies Story; + +export const Sizes = { + render: props => ( + <Stack> + <Inline> + <SegmentedControl defaultSelectedKey="day" {...props}> + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> + </SegmentedControl> + <SegmentedControl defaultSelectedKey="day" {...props} size="md"> + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> + </SegmentedControl> + </Inline> + <Inline> + <SegmentedControl defaultSelectedKey="unordered" {...props}> + <SegmentedControlItem id="unordered"> + <UnorderedListIcon /> + <Text>Unordered</Text> + </SegmentedControlItem> + <SegmentedControlItem id="ordered"> + <OrderedListIcon /> + <Text>Ordered</Text> + </SegmentedControlItem> + </SegmentedControl> + <SegmentedControl defaultSelectedKey="unordered" size="md" {...props}> + <SegmentedControlItem id="unordered"> + <UnorderedListIcon /> + <Text>Unordered</Text> + </SegmentedControlItem> + <SegmentedControlItem id="ordered"> + <OrderedListIcon /> + <Text>Ordered</Text> + </SegmentedControlItem> + </SegmentedControl> + </Inline> + <Inline> + <SegmentedControl defaultSelectedKey="unordered" {...props} > + <SegmentedControlItem aria-label="Unordered" id="unordered"><UnorderedListIcon /></SegmentedControlItem> + <SegmentedControlItem aria-label="Ordered" id="ordered"><OrderedListIcon /></SegmentedControlItem> + </SegmentedControl> + <SegmentedControl defaultSelectedKey="unordered" size="md" {...props} > + <SegmentedControlItem aria-label="Unordered" id="unordered"><UnorderedListIcon /></SegmentedControlItem> + <SegmentedControlItem aria-label="Ordered" id="ordered"><OrderedListIcon /></SegmentedControlItem> + </SegmentedControl> + </Inline> + </Stack> + ), + args: { + "aria-label": "Example of sizes" + } +} satisfies Story; + +export const WithIcons = { + render: props => ( + <SegmentedControl defaultSelectedKey="unordered" {...props}> + <SegmentedControlItem id="unordered"> + <UnorderedListIcon /> + <Text>Unordered</Text> + </SegmentedControlItem> + <SegmentedControlItem id="ordered"> + <OrderedListIcon /> + <Text>Ordered</Text> + </SegmentedControlItem> + </SegmentedControl> + ), + args: { + "aria-label": "List organization" + } +} satisfies Story; + +export const WithTrailingIcons = { + render: props => ( + <SegmentedControl defaultSelectedKey="unordered" {...props}> + <SegmentedControlItem id="unordered"> + <Text>Unordered</Text> + <UnorderedListIcon /> + </SegmentedControlItem> + <SegmentedControlItem id="ordered"> + <Text>Ordered</Text> + <OrderedListIcon /> + </SegmentedControlItem> + </SegmentedControl> + ), + args: { + "aria-label": "List organization" + } +} satisfies Story; + +export const OnlyIcons = { + render: props => ( + <SegmentedControl defaultSelectedKey="unordered" {...props} > + <SegmentedControlItem aria-label="Unordered" id="unordered"><UnorderedListIcon /></SegmentedControlItem> + <SegmentedControlItem aria-label="Ordered" id="ordered"><OrderedListIcon /></SegmentedControlItem> + </SegmentedControl> + ), + args: { + "aria-label": "List organization" + } +} satisfies Story; + +export const Disabled = { + render: props => ( + <Stack> + <SegmentedControl isDisabled defaultSelectedKey="day" {...props}> + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> + </SegmentedControl> + <SegmentedControl isDisabled defaultSelectedKey="unordered" {...props}> + <SegmentedControlItem id="unordered"> + <UnorderedListIcon /> + <Text>Unordered</Text> + </SegmentedControlItem> + <SegmentedControlItem id="ordered"> + <OrderedListIcon /> + <Text>Ordered</Text> + </SegmentedControlItem> + </SegmentedControl> + <SegmentedControl isDisabled defaultSelectedKey="unordered" {...props} > + <SegmentedControlItem aria-label="Unordered" id="unordered"><UnorderedListIcon /></SegmentedControlItem> + <SegmentedControlItem aria-label="Ordered" id="ordered"><OrderedListIcon /></SegmentedControlItem> + </SegmentedControl> + </Stack> + ), + args: { + "aria-label": "Examples of disabled segmented controls" + } +} satisfies Story; + diff --git a/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.ssr.test.tsx b/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.ssr.test.tsx new file mode 100644 index 00000000..cd7bb30e --- /dev/null +++ b/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.ssr.test.tsx @@ -0,0 +1,19 @@ +/** + * @jest-environment node + */ +import { renderToString } from "react-dom/server"; + +import { SegmentedControl } from "../../src/SegmentedControl.tsx"; + +describe("SegmentedControl", () => { + it("should render on the server", () => { + const renderOnServer = () => + renderToString( + <SegmentedControl> + test + </SegmentedControl> + ); + + expect(renderOnServer).not.toThrow(); + }); +}); diff --git a/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.test.tsx b/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.test.tsx new file mode 100644 index 00000000..775301c9 --- /dev/null +++ b/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.test.tsx @@ -0,0 +1,90 @@ +/* eslint-disable testing-library/no-node-access */ +/* Using closest to get the label is the best way, even react-aria does this. */ +import { render, screen } from "@hopper-ui/test-utils"; +import { createRef } from "react"; + +import { SegmentedControl, SegmentedControlItem } from "../../src/index.ts"; +import { SegmentedControlContext } from "../../src/SegmentedControlContext.ts"; + +describe("SegmentedControl", () => { + it("should render with default class", () => { + render(<SegmentedControl aria-label="options"><SegmentedControlItem id="day">Day</SegmentedControlItem><SegmentedControlItem id="week">Week</SegmentedControlItem></SegmentedControl>); + + const element = screen.getByRole("radiogroup"); + expect(element).toHaveClass("hop-SegmentedControl"); + }); + + it("should support custom class", () => { + render(<SegmentedControl aria-label="options" className="test"><SegmentedControlItem id="option1">option 1</SegmentedControlItem></SegmentedControl>); + + const element = screen.getByRole("radiogroup"); + expect(element).toHaveClass("hop-SegmentedControl"); + expect(element).toHaveClass("test"); + }); + + it("should support custom style", () => { + render(<SegmentedControl aria-label="options" marginTop="stack-sm" style={{ marginBottom: "13px" }}> + <SegmentedControlItem id="option1">option 1</SegmentedControlItem> + </SegmentedControl>); + + const element = screen.getByRole("radiogroup"); + expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" }); + }); + + it("should support DOM props", () => { + render(<SegmentedControl aria-label="options" data-foo="bar"><SegmentedControlItem id="option1">option 1</SegmentedControlItem></SegmentedControl>); + + const element = screen.getByRole("radiogroup"); + expect(element).toHaveAttribute("data-foo", "bar"); + }); + + it("should support slots", () => { + render( + <SegmentedControlContext.Provider value={{ slots: { test: { "aria-label": "test" } } }}> + <SegmentedControl slot="test"><SegmentedControlItem id="option1">option 1</SegmentedControlItem></SegmentedControl> + </SegmentedControlContext.Provider> + ); + + const element = screen.getByRole("radiogroup"); + + expect(element).toHaveAttribute("slot", "test"); + expect(element).toHaveAttribute("aria-label", "test"); + }); + + it("should support refs", () => { + const ref = createRef<HTMLDivElement>(); + render(<SegmentedControl aria-label="options" ref={ref}><SegmentedControlItem id="option1">option 1</SegmentedControlItem></SegmentedControl>); + + expect(ref.current).not.toBeNull(); + expect(ref.current instanceof HTMLDivElement).toBeTruthy(); + }); + + it("should pass the size to the SegmentedControlItem.", () => { + const testId = "SegmentedControlItem"; + + render( + <SegmentedControl aria-label="options" size="sm"> + <SegmentedControlItem data-testid={testId} id="option1"> + option 1 + </SegmentedControlItem> + </SegmentedControl> + ); + + const item = screen.getByTestId(testId); + expect(item).toHaveClass("hop-SegmentedControlItem--sm"); + }); + + it("should be disabled and pass it to the SegmentedControlItem.", () => { + const testId = "SegmentedControlItem"; + + render(<SegmentedControl aria-label="options" isDisabled><SegmentedControlItem data-testid={testId} id="option1">option 1</SegmentedControlItem></SegmentedControl>); + + const element = screen.getByRole("radiogroup"); + expect(element).toHaveAttribute("data-disabled", "true"); + + const item = screen.getByTestId(testId); + expect(item).toHaveAttribute("data-disabled", "true"); + + expect(item).toBeDisabled(); + }); +}); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 204f3cb6..7733b5dc 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -21,6 +21,7 @@ export * from "./ListBox/index.ts"; export * from "./ListBoxSection/index.ts"; export * from "./overlays/Popover/index.ts"; export * from "./radio/index.ts"; +export * from "./SegmentedControl/index.ts"; export * from "./Select/index.ts"; export * from "./Spinner/index.ts"; export * from "./switch/index.ts"; From c164134333d17795a30f05332fbddf60fbbeb182 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Wed, 8 Jan 2025 16:56:16 -0500 Subject: [PATCH 02/17] Revert weird changes --- .../app/ui/components/componentExample/ComponentExample.tsx | 2 +- apps/docs/app/ui/layout/header/Header.tsx | 2 +- apps/docs/components/themeSwitch/ThemeSwitch.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/app/ui/components/componentExample/ComponentExample.tsx b/apps/docs/app/ui/components/componentExample/ComponentExample.tsx index 16970e7e..6c59dd9d 100644 --- a/apps/docs/app/ui/components/componentExample/ComponentExample.tsx +++ b/apps/docs/app/ui/components/componentExample/ComponentExample.tsx @@ -4,7 +4,7 @@ import clsx from "clsx"; import { memo, useEffect, useState, type ReactNode } from "react"; import { CodeIcon, Icon, StackblitzIcon } from "@/components/icon"; -import { ToggleButton } from "@/components/ToggleButton/ToggleButton.tsx"; +import { ToggleButton } from "@/components/toggleButton/ToggleButton.tsx"; import { useToggle } from "@/hooks/useToggle.ts"; import ComponentPreviewWrapper from "./ComponentPreviewWrapper.tsx"; diff --git a/apps/docs/app/ui/layout/header/Header.tsx b/apps/docs/app/ui/layout/header/Header.tsx index b9cfbe21..89609556 100644 --- a/apps/docs/app/ui/layout/header/Header.tsx +++ b/apps/docs/app/ui/layout/header/Header.tsx @@ -12,7 +12,7 @@ import { Icon, ProductMenuIcon } from "@/components/icon"; import LinkIconButton from "@/components/iconButton/LinkIconButton"; import { Popover, PopoverContext, PopoverTrigger } from "@/components/popover/Popover.tsx"; import ThemeSwitch from "@/components/themeSwitch/ThemeSwitch"; -import { ToggleButton, ToggleButtonContext } from "@/components/ToggleButton/ToggleButton"; +import { ToggleButton, ToggleButtonContext } from "@/components/toggleButton/ToggleButton.tsx"; import { navigation } from "@/configs/navigation"; import { ThemeContext, type ColorScheme } from "@/context/theme/ThemeProvider.tsx"; import { useIsMobile } from "@/hooks/useIsMobile"; diff --git a/apps/docs/components/themeSwitch/ThemeSwitch.tsx b/apps/docs/components/themeSwitch/ThemeSwitch.tsx index 650dc4a8..97f30a18 100644 --- a/apps/docs/components/themeSwitch/ThemeSwitch.tsx +++ b/apps/docs/components/themeSwitch/ThemeSwitch.tsx @@ -1,4 +1,4 @@ -import { ToggleButton } from "@/components/ToggleButton/ToggleButton"; +import { ToggleButton } from "@/components/toggleButton/ToggleButton"; import Icon from "@/components/themeSwitch/ThemeSwitchIcons"; import type { ColorScheme } from "@/context/theme/ThemeProvider"; From ba5e3eb0a17c651f254eaf8a54b7b8ec8333cc41 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Thu, 9 Jan 2025 17:49:56 -0500 Subject: [PATCH 03/17] Fix animation to match more Spectrum --- .../src/SegmentedControl.module.css | 16 ----- .../SegmentedControl/src/SegmentedControl.tsx | 53 ++++++--------- .../src/SegmentedControlContext.ts | 9 ++- .../src/SegmentedControlItem.module.css | 20 ++++-- .../src/SegmentedControlItem.tsx | 68 +++++++++++++++---- 5 files changed, 93 insertions(+), 73 deletions(-) diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.module.css b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css index 28a90d45..63ca6130 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css @@ -11,8 +11,6 @@ --hop-SegmentedControl-item-border-radius: var(--hop-shape-rounded-sm); --hop-SegmentedControl-item-offset: 0rem; - position: relative; - display: flex; gap: var(--hop-SegmentedControl-gap); align-items: center; @@ -26,18 +24,4 @@ border-radius: var(--hop-SegmentedControl-border-radius) } -.hop-SegmentedControl::after { - content: ''; - - position: absolute; - inset-block-start: var(--hop-SegmentedControl-padding); - inset-inline-start: var(--hop-SegmentedControl-item-offset); - - inline-size: var(--hop-SegmentedControl-item-width); - block-size: calc(100% - 2 * var(--hop-SegmentedControl-padding)); - background: var(--hop-neutral-surface-selected); - border-radius: var(--hop-SegmentedControl-item-border-radius); - - transition: left 0.3s; -} diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx index dd24d807..d29b8ac5 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx @@ -1,11 +1,11 @@ import { useStyledSystem, type ResponsiveProp, type StyledComponentProps } from "@hopper-ui/styled-system"; import clsx from "clsx"; -import { forwardRef, useEffect, useMemo, useState, type CSSProperties, type ForwardedRef } from "react"; -import { Provider, ToggleButtonGroup, useContextProps, type Key } from "react-aria-components"; +import { forwardRef, useContext, useEffect, useRef, type CSSProperties, type ForwardedRef } from "react"; +import { Provider, ToggleButtonGroup, ToggleGroupStateContext, useContextProps, type Key } from "react-aria-components"; import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts"; -import { SegmentedControlContext } from "./SegmentedControlContext.ts"; +import { InternalSegmentedControlContext, SegmentedControlContext } from "./SegmentedControlContext.ts"; import type { SegmentedControlItemSize } from "./SegmentedControlItem.tsx"; import { SegmentedControlItemContext } from "./SegmentedControlItemContext.ts"; @@ -39,6 +39,10 @@ export interface SegmentedControlProps extends StyledComponentProps<BaseComponen const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDivElement>) => { [props, ref] = useContextProps(props, ref, SegmentedControlContext); + const state = useContext(ToggleGroupStateContext); + + const prevRef = useRef<DOMRect | null>(null); + const currentSelectedRef = useRef<HTMLDivElement>(null); const { stylingProps, ...ownProps } = useStyledSystem(props); const { @@ -49,11 +53,10 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi defaultSelectedKey, selectedKey, onSelectionChange, + size, ...otherProps } = ownProps; - const [selected, setSelected] = useState<Key | undefined>(defaultSelectedKey ?? selectedKey); - const classNames = clsx( GlobalSegmentedControlCssSelector, cssModule( @@ -76,39 +79,20 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi return; } - setSelected(firstKey); - onSelectionChange?.(firstKey); - }; - - const selectedKeys = useMemo(() => { - if (onSelectionChange) { - return selectedKey != null ? [selectedKey] : undefined; + if (currentSelectedRef.current) { + prevRef.current = currentSelectedRef?.current.getBoundingClientRect(); } - return selected ? [selected] : undefined; - }, [onSelectionChange, selectedKey, selected]); + onSelectionChange?.(firstKey); + }; useEffect(() => { - const container = ref.current; - - // Code to create sliding animation background between buttons when selecting a new value - if (container && (selectedKeys || defaultSelectedKey)) { - const { childNodes } = container; - - const childNodesArray = Array.from(childNodes) as HTMLElement[]; - - const [firstKey] = selectedKeys ?? []; - const key = firstKey ?? defaultSelectedKey; - const selectedNode = childNodesArray.find(x => x.getAttribute("data-key") === key); - - if (!selectedNode) { - return; - } + const key = defaultSelectedKey ?? selectedKey; - container.style.setProperty("--hop-SegmentedControl-item-width", `${selectedNode.offsetWidth}px`); - container.style.setProperty("--hop-SegmentedControl-item-offset", `${selectedNode.offsetLeft}px`); + if (key) { + state?.toggleKey(key); } - }, [ref, selectedKeys, defaultSelectedKey]); + }, []); return ( <ToggleButtonGroup @@ -116,7 +100,7 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi className={classNames} style={mergedStyles} slot={slot ?? undefined} - selectedKeys={selectedKeys} + selectedKeys={selectedKey != null ? [selectedKey] : undefined} defaultSelectedKeys={defaultSelectedKey != null ? [defaultSelectedKey] : undefined} orientation="horizontal" onSelectionChange={onChange} @@ -124,7 +108,8 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi > <Provider values={[ - [SegmentedControlItemContext, otherProps] + [SegmentedControlItemContext, { size, isDisabled: otherProps.isDisabled }], + [InternalSegmentedControlContext, { prevRef, currentSelectedRef }] ]} > {children} diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts b/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts index b5397cf0..43fdea0a 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts +++ b/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts @@ -1,4 +1,4 @@ -import { createContext } from "react"; +import { createContext, type MutableRefObject, type RefObject } from "react"; import type { ContextValue } from "react-aria-components"; import type { SegmentedControlProps } from "./SegmentedControl.tsx"; @@ -6,3 +6,10 @@ import type { SegmentedControlProps } from "./SegmentedControl.tsx"; export const SegmentedControlContext = createContext<ContextValue<SegmentedControlProps, HTMLDivElement>>({}); SegmentedControlContext.displayName = "SegmentedControlContext"; + +interface InternalSegmentedControlContextProps { + prevRef?: MutableRefObject<DOMRect | null>; + currentSelectedRef?: RefObject<HTMLDivElement>; +} + +export const InternalSegmentedControlContext = createContext<InternalSegmentedControlContextProps>({}); diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css index d0ac2918..b925e28f 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css @@ -6,8 +6,6 @@ --hop-SegmentedControlItem-border: none; --hop-SegmentedControlItem-border-radius: var(--hop-shape-rounded-sm); --hop-SegmentedControlItem-cursor: auto; - --hop-SegmentedControlItem-z-index: 1; - --hop-SegmentedControlItem-color-transition: color 0.3s; /* Small */ --hop-SegmentedControlItem-sm-padding: 0.125rem var(--hop-space-inset-md); @@ -17,22 +15,23 @@ cursor: var(--hop-SegmentedControlItem-cursor); - z-index: var(--hop-SegmentedControlItem-z-index); + position: relative; display: flex; gap: var(--hop-SegmentedControlItem-gap); align-items: center; justify-content: center; - padding: var(--hop-SegmentedControlItem-sm-padding); + padding: var(--hop-SegmentedControlItem-padding, var(--hop-SegmentedControlItem-sm-padding)); color: var(--hop-SegmentedControlItem-color); - background: var(--hop-SegmentedControlItem-background); + background: var(--hop-SegmentedControlItem-background) +} + +.hop-SegmentedControlItem, .hop-SegmentedControlItem__slider { border: var(--hop-SegmentedControlItem-border); border-radius: var(--hop-SegmentedControlItem-border-radius); - - transition: var(--hop-SegmentedControlItem-color-transition); } .hop-SegmentedControlItem:not([data-disabled]) { @@ -62,3 +61,10 @@ .hop-SegmentedControlItem--md { --hop-SegmentedControlItem-padding: var(--hop-SegmentedControlItem-md-padding); } + +.hop-SegmentedControlItem__slider { + position: absolute; + inline-size: 100%; + block-size: 100%; + background: var(--hop-neutral-surface-selected); +} diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index 5674b683..57e2c4cd 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -1,12 +1,13 @@ import { IconContext } from "@hopper-ui/icons"; import { useStyledSystem, type ResponsiveProp, type StyledComponentProps } from "@hopper-ui/styled-system"; import clsx from "clsx"; -import { forwardRef, type CSSProperties, type ForwardedRef } from "react"; -import { Provider, ToggleButton, useContextProps, type Key } from "react-aria-components"; +import { forwardRef, useContext, useLayoutEffect, type CSSProperties, type ForwardedRef } from "react"; +import { Provider, ToggleButton, ToggleGroupStateContext, useContextProps, type Key } from "react-aria-components"; import { Text, TextContext } from "../../typography/index.ts"; import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts"; +import { InternalSegmentedControlContext } from "./SegmentedControlContext.ts"; import { SegmentedControlItemContext } from "./SegmentedControlItemContext.ts"; import styles from "./SegmentedControlItem.module.css"; @@ -33,6 +34,9 @@ export interface SegmentedControlItemProps extends Omit<StyledComponentProps<Bas const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRef<HTMLButtonElement>) => { [props, ref] = useContextProps(props, ref, SegmentedControlItemContext); + const { prevRef, currentSelectedRef } = useContext(InternalSegmentedControlContext); + const state = useContext(ToggleGroupStateContext); + const itemSelected = state?.selectedKeys.has(props.id); const { stylingProps, ...ownProps } = useStyledSystem(props); const { @@ -60,6 +64,34 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe ...stylingProps.style }; + const isReduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + + useLayoutEffect(() => { + if (isReduced || !itemSelected || !prevRef?.current || !currentSelectedRef?.current) { + return; + } + + const prevSlider = prevRef.current; + const currentSlider = currentSelectedRef.current; + + const currentItem = currentSlider.getBoundingClientRect(); + + const deltaX = prevSlider.left - currentItem?.left; + + currentSelectedRef.current.animate( + [ + { transform: `translateX(${deltaX}px)`, width: `${prevSlider.width}px` }, + { transform: "translateX(0px)", width: `${currentItem.width}px` } + ], + { + duration: 200, + easing: "ease-out" + } + ); + + prevRef.current = null; + }, [currentSelectedRef, isReduced, itemSelected, prevRef]); + return ( <ToggleButton {...otherProps} @@ -67,20 +99,26 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe className={classNames} style={mergedStyles} slot={slot ?? undefined} - data-key={props.id} > - <Provider - values={[ - [IconContext, { - size - }], - [TextContext, { - size: "sm" - }] - ]} - > - {typeof children === "string" ? <Text>{children}</Text> : children} - </Provider> + {({ isSelected }) => ( + <> + {isSelected && <div className={styles["hop-SegmentedControlItem__slider"]} ref={currentSelectedRef} />} + <Provider + values={[ + [IconContext, { + size + }], + [TextContext, { + size: "sm", + zIndex: 1 + }] + ]} + > + {typeof children === "string" ? <Text>{children}</Text> : children} + </Provider> + </> + )} + </ToggleButton> ); }; From 6dfc40e175040ec1624435303a17c76550627f2c Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Thu, 9 Jan 2025 18:12:41 -0500 Subject: [PATCH 04/17] More fixes --- .../SegmentedControl/src/SegmentedControl.tsx | 1 + .../src/SegmentedControlItem.module.css | 21 +++++++++++-------- .../src/SegmentedControlItem.tsx | 3 ++- .../tests/jest/SegmentedControl.test.tsx | 12 ++++------- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx index d29b8ac5..2d2093b3 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx @@ -92,6 +92,7 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi if (key) { state?.toggleKey(key); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css index b925e28f..943e9fed 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css @@ -8,10 +8,13 @@ --hop-SegmentedControlItem-cursor: auto; /* Small */ - --hop-SegmentedControlItem-sm-padding: 0.125rem var(--hop-space-inset-md); + --hop-SegmentedControlItem-sm-padding: 0.125rem var(--hop-space-inset-md); /* Medium */ - --hop-SegmentedControlItem-md-padding: 0.375rem var(--hop-space-inset-md); + --hop-SegmentedControlItem-md-padding: 0.375rem var(--hop-space-inset-md); + + /* Slider */ + --hop-SegmentedControlItem-slider-background: var(--hop-neutral-surface-selected); cursor: var(--hop-SegmentedControlItem-cursor); @@ -34,6 +37,13 @@ border-radius: var(--hop-SegmentedControlItem-border-radius); } +.hop-SegmentedControlItem__slider { + position: absolute; + inline-size: 100%; + block-size: 100%; + background: var(--hop-SegmentedControlItem-slider-background); +} + .hop-SegmentedControlItem:not([data-disabled]) { --hop-SegmentedControlItem-cursor: pointer; } @@ -61,10 +71,3 @@ .hop-SegmentedControlItem--md { --hop-SegmentedControlItem-padding: var(--hop-SegmentedControlItem-md-padding); } - -.hop-SegmentedControlItem__slider { - position: absolute; - inline-size: 100%; - block-size: 100%; - background: var(--hop-neutral-surface-selected); -} diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index 57e2c4cd..bd283a8c 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -106,7 +106,8 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe <Provider values={[ [IconContext, { - size + size, + zIndex: 1 }], [TextContext, { size: "sm", diff --git a/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.test.tsx b/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.test.tsx index 775301c9..1b025f11 100644 --- a/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.test.tsx +++ b/packages/components/src/SegmentedControl/tests/jest/SegmentedControl.test.tsx @@ -60,29 +60,25 @@ describe("SegmentedControl", () => { }); it("should pass the size to the SegmentedControlItem.", () => { - const testId = "SegmentedControlItem"; - render( <SegmentedControl aria-label="options" size="sm"> - <SegmentedControlItem data-testid={testId} id="option1"> + <SegmentedControlItem id="option1"> option 1 </SegmentedControlItem> </SegmentedControl> ); - const item = screen.getByTestId(testId); + const item = screen.getByRole("radio"); expect(item).toHaveClass("hop-SegmentedControlItem--sm"); }); it("should be disabled and pass it to the SegmentedControlItem.", () => { - const testId = "SegmentedControlItem"; - - render(<SegmentedControl aria-label="options" isDisabled><SegmentedControlItem data-testid={testId} id="option1">option 1</SegmentedControlItem></SegmentedControl>); + render(<SegmentedControl aria-label="options" isDisabled><SegmentedControlItem id="option1">option 1</SegmentedControlItem></SegmentedControl>); const element = screen.getByRole("radiogroup"); expect(element).toHaveAttribute("data-disabled", "true"); - const item = screen.getByTestId(testId); + const item = screen.getByRole("radio"); expect(item).toHaveAttribute("data-disabled", "true"); expect(item).toBeDisabled(); From c1db0db941163d471f2771cdf4acd73b0edcd5b7 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 00:52:31 -0500 Subject: [PATCH 05/17] More PR fixes --- apps/docs/content/components/buttons/SegmentedControl.mdx | 3 ++- packages/components/src/SegmentedControl/docs/controlled.tsx | 2 +- packages/components/src/SegmentedControl/docs/iconOnly.tsx | 4 ++-- .../components/src/SegmentedControl/src/SegmentedControl.tsx | 5 +++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/components/buttons/SegmentedControl.mdx b/apps/docs/content/components/buttons/SegmentedControl.mdx index c3ae2c27..ed55fe66 100644 --- a/apps/docs/content/components/buttons/SegmentedControl.mdx +++ b/apps/docs/content/components/buttons/SegmentedControl.mdx @@ -12,7 +12,8 @@ links: ## Usage ### Selected -A segmented control supports single selection. At any given time, only one item can be active. +A segmented control can have an item initially selected, by using `defaultSelectedKey` for uncontrolled or `selectedKey` for controlled. +Here's an example where one item is selected using defaultSelectedKey. <Example src="SegmentedControl/docs/selected" /> diff --git a/packages/components/src/SegmentedControl/docs/controlled.tsx b/packages/components/src/SegmentedControl/docs/controlled.tsx index 05303931..cb65fe56 100644 --- a/packages/components/src/SegmentedControl/docs/controlled.tsx +++ b/packages/components/src/SegmentedControl/docs/controlled.tsx @@ -2,7 +2,7 @@ import { SegmentedControl, SegmentedControlItem, type Key } from "@hopper-ui/com import { useState } from "react"; export default function Example() { - const [selectedKey, setSelectedKey] = useState<Key>(); + const [selectedKey, setSelectedKey] = useState<Key>("day"); const handleSelectionChange = (key: Key) => { if (selectedKey === key) { diff --git a/packages/components/src/SegmentedControl/docs/iconOnly.tsx b/packages/components/src/SegmentedControl/docs/iconOnly.tsx index 2dd3de56..06b0f8f1 100644 --- a/packages/components/src/SegmentedControl/docs/iconOnly.tsx +++ b/packages/components/src/SegmentedControl/docs/iconOnly.tsx @@ -5,10 +5,10 @@ export default function Example() { return ( <SegmentedControl aria-label="List ordering"> <SegmentedControlItem id="unordered"> - <UnorderedListIcon /> + <UnorderedListIcon aria-label="unordered" /> </SegmentedControlItem> <SegmentedControlItem id="ordered"> - <OrderedListIcon /> + <OrderedListIcon aria-label="ordered" /> </SegmentedControlItem> </SegmentedControl> ); diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx index 2d2093b3..e38eef5e 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx @@ -101,10 +101,11 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi className={classNames} style={mergedStyles} slot={slot ?? undefined} - selectedKeys={selectedKey != null ? [selectedKey] : undefined} - defaultSelectedKeys={defaultSelectedKey != null ? [defaultSelectedKey] : undefined} orientation="horizontal" onSelectionChange={onChange} + selectedKeys={selectedKey != null ? [selectedKey] : undefined} + defaultSelectedKeys={defaultSelectedKey != null ? [defaultSelectedKey] : undefined} + disallowEmptySelection {...otherProps} > <Provider From d51f75735b4f4dbfa7b839943f4a4bc0619e309b Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 00:59:31 -0500 Subject: [PATCH 06/17] More fixes and try to fix build --- .../components/buttons/SegmentedControl.mdx | 3 ++- .../components/src/SegmentedControl/docs/icon.tsx | 2 +- .../src/SegmentedControl/src/SegmentedControl.tsx | 2 +- .../src/SegmentedControlItem.module.css | 12 ++++++++++++ .../SegmentedControl/src/SegmentedControlItem.tsx | 15 ++++++++++++--- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/components/buttons/SegmentedControl.mdx b/apps/docs/content/components/buttons/SegmentedControl.mdx index ed55fe66..64ec325f 100644 --- a/apps/docs/content/components/buttons/SegmentedControl.mdx +++ b/apps/docs/content/components/buttons/SegmentedControl.mdx @@ -28,7 +28,8 @@ Items within a segmented control can contain only icons. An accessible name must <Example src="SegmentedControl/docs/iconOnly" /> ### Icon -A segmented control can contain items with icons, leading or trailing. +A segmented control can contain items with icons, starting or ending. +**Non standard** starting icons can be provided to handle special cases. However, think twice before adding start icons, end icons should be your go to. <Example src="SegmentedControl/docs/icon" /> diff --git a/packages/components/src/SegmentedControl/docs/icon.tsx b/packages/components/src/SegmentedControl/docs/icon.tsx index ee4b46a2..6b14ac61 100644 --- a/packages/components/src/SegmentedControl/docs/icon.tsx +++ b/packages/components/src/SegmentedControl/docs/icon.tsx @@ -5,7 +5,7 @@ export default function Example() { return ( <SegmentedControl aria-label="List ordering"> <SegmentedControlItem id="unordered"> - <UnorderedListIcon /> + <UnorderedListIcon slot="start-icon" /> <Text>Unordered</Text> </SegmentedControlItem> <SegmentedControlItem id="ordered"> diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx index e38eef5e..16c767ad 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx @@ -42,7 +42,7 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi const state = useContext(ToggleGroupStateContext); const prevRef = useRef<DOMRect | null>(null); - const currentSelectedRef = useRef<HTMLDivElement>(null); + const currentSelectedRef = useRef<HTMLDivElement | null>(null); const { stylingProps, ...ownProps } = useStyledSystem(props); const { diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css index 943e9fed..1868637c 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css @@ -71,3 +71,15 @@ .hop-SegmentedControlItem--md { --hop-SegmentedControlItem-padding: var(--hop-SegmentedControlItem-md-padding); } + +.hop-SegmentedControlItem__icon { + order: 2 +} + +.hop-SegmentedControlItem__text { + order: 1 +} + +.hop-SegmentedControlItem__start-icon { + order: 0 +} diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index bd283a8c..992d87cf 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -2,7 +2,7 @@ import { IconContext } from "@hopper-ui/icons"; import { useStyledSystem, type ResponsiveProp, type StyledComponentProps } from "@hopper-ui/styled-system"; import clsx from "clsx"; import { forwardRef, useContext, useLayoutEffect, type CSSProperties, type ForwardedRef } from "react"; -import { Provider, ToggleButton, ToggleGroupStateContext, useContextProps, type Key } from "react-aria-components"; +import { DEFAULT_SLOT, Provider, ToggleButton, ToggleGroupStateContext, useContextProps, type Key } from "react-aria-components"; import { Text, TextContext } from "../../typography/index.ts"; import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts"; @@ -107,11 +107,20 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe values={[ [IconContext, { size, - zIndex: 1 + zIndex: 1, + slots: { + [DEFAULT_SLOT]: { + className: styles["hop-SegmentedControlItem__icon"] + }, + "start-icon": { + className: styles["hop-SegmentedControlItem__start-icon"] + } + } }], [TextContext, { size: "sm", - zIndex: 1 + zIndex: 1, + className: styles["hop-SegmentedControlItem__text"] }] ]} > From bd9aaddbed6728ab4d04ecb536e888896c1ff04e Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 01:14:53 -0500 Subject: [PATCH 07/17] Not sure --- .../components/src/SegmentedControl/src/SegmentedControl.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx index 16c767ad..e38eef5e 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx @@ -42,7 +42,7 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi const state = useContext(ToggleGroupStateContext); const prevRef = useRef<DOMRect | null>(null); - const currentSelectedRef = useRef<HTMLDivElement | null>(null); + const currentSelectedRef = useRef<HTMLDivElement>(null); const { stylingProps, ...ownProps } = useStyledSystem(props); const { From 42f486a741ffe6aac70974c3191625333768548d Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 01:21:03 -0500 Subject: [PATCH 08/17] Try casting --- .../components/src/SegmentedControl/src/SegmentedControl.tsx | 2 +- .../src/SegmentedControl/src/SegmentedControlContext.ts | 2 +- .../src/SegmentedControl/src/SegmentedControlItem.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx index e38eef5e..17df031a 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx @@ -80,7 +80,7 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi } if (currentSelectedRef.current) { - prevRef.current = currentSelectedRef?.current.getBoundingClientRect(); + prevRef.current = currentSelectedRef.current.getBoundingClientRect(); } onSelectionChange?.(firstKey); diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts b/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts index 43fdea0a..b5b23e1a 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts +++ b/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts @@ -9,7 +9,7 @@ SegmentedControlContext.displayName = "SegmentedControlContext"; interface InternalSegmentedControlContextProps { prevRef?: MutableRefObject<DOMRect | null>; - currentSelectedRef?: RefObject<HTMLDivElement>; + currentSelectedRef?: RefObject<HTMLDivElement | null>; } export const InternalSegmentedControlContext = createContext<InternalSegmentedControlContextProps>({}); diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index 992d87cf..4dbaea81 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -1,7 +1,7 @@ import { IconContext } from "@hopper-ui/icons"; import { useStyledSystem, type ResponsiveProp, type StyledComponentProps } from "@hopper-ui/styled-system"; import clsx from "clsx"; -import { forwardRef, useContext, useLayoutEffect, type CSSProperties, type ForwardedRef } from "react"; +import { forwardRef, useContext, useLayoutEffect, type CSSProperties, type ForwardedRef, type RefObject } from "react"; import { DEFAULT_SLOT, Provider, ToggleButton, ToggleGroupStateContext, useContextProps, type Key } from "react-aria-components"; import { Text, TextContext } from "../../typography/index.ts"; @@ -102,7 +102,7 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe > {({ isSelected }) => ( <> - {isSelected && <div className={styles["hop-SegmentedControlItem__slider"]} ref={currentSelectedRef} />} + {isSelected && <div className={styles["hop-SegmentedControlItem__slider"]} ref={currentSelectedRef as RefObject<HTMLDivElement>} />} <Provider values={[ [IconContext, { From feabf8fa4278984ae5c765f48ca1985bc74dcdd7 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 10:40:38 -0500 Subject: [PATCH 09/17] Fix all comments --- .../components/buttons/SegmentedControl.mdx | 5 ++ apps/docs/examples/Preview.ts | 3 + .../src/SegmentedControl/docs/justified.tsx | 12 +++ .../src/SegmentedControl.module.css | 16 ++-- .../src/SegmentedControlItem.module.css | 84 +++++++++++++------ .../src/SegmentedControlItem.tsx | 10 ++- 6 files changed, 93 insertions(+), 37 deletions(-) create mode 100644 packages/components/src/SegmentedControl/docs/justified.tsx diff --git a/apps/docs/content/components/buttons/SegmentedControl.mdx b/apps/docs/content/components/buttons/SegmentedControl.mdx index 64ec325f..675174c3 100644 --- a/apps/docs/content/components/buttons/SegmentedControl.mdx +++ b/apps/docs/content/components/buttons/SegmentedControl.mdx @@ -33,6 +33,11 @@ A segmented control can contain items with icons, starting or ending. <Example src="SegmentedControl/docs/icon" /> +### Justified +A segmented control can have items with similar widths. + +<Example src="SegmentedControl/docs/justified" /> + ### Controlled A segmented control can have a controlled selected value. In this example, it shows how it is possible to select an option. diff --git a/apps/docs/examples/Preview.ts b/apps/docs/examples/Preview.ts index 84af1001..87a6ebdf 100644 --- a/apps/docs/examples/Preview.ts +++ b/apps/docs/examples/Preview.ts @@ -110,6 +110,9 @@ export const Previews: Record<string, Preview> = { "SegmentedControl/docs/icon": { component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/icon.tsx")) }, + "SegmentedControl/docs/justified": { + component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/justified.tsx")) + }, "SegmentedControl/docs/controlled": { component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/controlled.tsx")) }, diff --git a/packages/components/src/SegmentedControl/docs/justified.tsx b/packages/components/src/SegmentedControl/docs/justified.tsx new file mode 100644 index 00000000..5db4cef6 --- /dev/null +++ b/packages/components/src/SegmentedControl/docs/justified.tsx @@ -0,0 +1,12 @@ +import { SegmentedControl, SegmentedControlItem } from "@hopper-ui/components"; + +export default function Example() { + return ( + <SegmentedControl width="100%" aria-label="Types of frog"> + <SegmentedControlItem id="common" flex={1}>Common</SegmentedControlItem> + <SegmentedControlItem id="american" flex={1}>American Bullfrog</SegmentedControlItem> + <SegmentedControlItem id="month" flex={1}>Red-Eyed Tree</SegmentedControlItem> + <SegmentedControlItem id="year" flex={1}>Golden Mantella</SegmentedControlItem> + </SegmentedControl> + ); +} diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.module.css b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css index 63ca6130..ee8af51c 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css @@ -5,23 +5,21 @@ --hop-SegmentedControl-background: var(--hop-neutral-surface); --hop-SegmentedControl-border: 0.0625rem solid var(--hop-neutral-border); --hop-SegmentedControl-border-radius: var(--hop-shape-rounded-md); + --hop-SegmentedControl-display: flex; + --hop-SegmentedControl-align-items: center; + --hop-SegmentedControl-justify-content: center; - /* Item background selected animation */ - --hop-SegmentedControl-item-width: 0rem; - --hop-SegmentedControl-item-border-radius: var(--hop-shape-rounded-sm); - --hop-SegmentedControl-item-offset: 0rem; - - display: flex; + display: var(--hop-SegmentedControl-display); gap: var(--hop-SegmentedControl-gap); - align-items: center; - justify-content: center; + align-items: var(--hop-SegmentedControl-align-items); + justify-content: var(--hop-SegmentedControl-justify-content); inline-size: var(--hop-SegmentedControl-witdh); padding: var(--hop-SegmentedControl-padding); background: var(--hop-SegmentedControl-background); border: var(--hop-SegmentedControl-border); - border-radius: var(--hop-SegmentedControl-border-radius) + border-radius: var(--hop-SegmentedControl-border-radius); } diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css index 1868637c..f16960aa 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css @@ -5,7 +5,11 @@ --hop-SegmentedControlItem-background: transparent; --hop-SegmentedControlItem-border: none; --hop-SegmentedControlItem-border-radius: var(--hop-shape-rounded-sm); - --hop-SegmentedControlItem-cursor: auto; + --hop-SegmentedControlItem-cursor: pointer; + --hop-SegmentedControlItem-position: relative; + --hop-SegmentedControlItem-display: flex; + --hop-SegmentedControlItem-align-items: center; + --hop-SegmentedControlItem-justify-content: center; /* Small */ --hop-SegmentedControlItem-sm-padding: 0.125rem var(--hop-space-inset-md); @@ -15,21 +19,51 @@ /* Slider */ --hop-SegmentedControlItem-slider-background: var(--hop-neutral-surface-selected); + --hop-SegmentedControlItem-slider-position: absolute; + --hop-SegmentedControlItem-slider-width: 100%; + --hop-SegmentedControlItem-slider-height: 100%; + + /* Focused */ + --hop-SegmentedControlItem-color-focused: var(--hop-neutral-text-hover); + --hop-SegmentedControlItem-background-focused: var(--hop-neutral-surface-hover); + + /* Hovered */ + --hop-SegmentedControlItem-color-hovered: var(--hop-neutral-text-hover); + --hop-SegmentedControlItem-background-hovered: var(--hop-neutral-surface-hover); + + /* Pressed */ + --hop-SegmentedControlItem-color-pressed: var(--hop-neutral-text-press); + --hop-SegmentedControlItem-background-pressed: var(--hop-neutral-surface-press); + + /* Selected */ + --hop-SegmentedControlItem-color-selected: var(--hop-neutral-text-selected); + --hop-SegmentedControlItem-background-selected: transparent; - cursor: var(--hop-SegmentedControlItem-cursor); + /* Disabled */ + --hop-SegmentedControlItem-color-disabled: var(--hop-neutral-text-disabled); + --hop-SegmentedControlItem-background-disabled: var(--hop-neutral-surface); + --hop-SegmentedControlItem-cursor-disabled: auto; - position: relative; + /* Internal variables */ + --color: var(--hop-SegmentedControlItem-color); + --cursor: var(--hop-SegmentedControlItem-cursor); + --background: var(--hop-SegmentedControlItem-background); + --padding: var(--hop-SegmentedControlItem-sm-padding); - display: flex; + cursor: var(--cursor); + + position: var(--hop-SegmentedControlItem-position); + + display: var(--hop-SegmentedControlItem-display); gap: var(--hop-SegmentedControlItem-gap); - align-items: center; - justify-content: center; + align-items: var(--hop-SegmentedControlItem-align-items); + justify-content: var(--hop-SegmentedControlItem-justify-content); - padding: var(--hop-SegmentedControlItem-padding, var(--hop-SegmentedControlItem-sm-padding)); + padding: var(--padding); - color: var(--hop-SegmentedControlItem-color); + color: var(--color); - background: var(--hop-SegmentedControlItem-background) + background: var(--background); } .hop-SegmentedControlItem, .hop-SegmentedControlItem__slider { @@ -38,38 +72,40 @@ } .hop-SegmentedControlItem__slider { - position: absolute; - inline-size: 100%; - block-size: 100%; + position: var(--hop-SegmentedControlItem-slider-position); + inline-size: var(--hop-SegmentedControlItem-slider-width); + block-size: var(--hop-SegmentedControlItem-slider-height); background: var(--hop-SegmentedControlItem-slider-background); } -.hop-SegmentedControlItem:not([data-disabled]) { - --hop-SegmentedControlItem-cursor: pointer; +.hop-SegmentedControlItem[data-hovered] { + --color: var(--hop-SegmentedControlItem-color-hovered); + --background: var(--hop-SegmentedControlItem-background-hovered); } -.hop-SegmentedControlItem[data-hovered], .hop-SegmentedControlItem[data-focused] { - --hop-SegmentedControlItem-color: var(--hop-neutral-text-hover); - --hop-SegmentedControlItem-background: var(--hop-neutral-surface-hover); +.hop-SegmentedControlItem[data-focused] { + --color: var(--hop-SegmentedControlItem-color-focused); + --background: var(--hop-SegmentedControlItem-background-focused); } .hop-SegmentedControlItem[data-pressed] { - --hop-SegmentedControlItem-color: var(--hop-neutral-text-press); - --hop-SegmentedControlItem-background: var(--hop-neutral-surface-press); + --color: var(--hop-SegmentedControlItem-color-pressed); + --background: var(--hop-SegmentedControlItem-background-pressed); } .hop-SegmentedControlItem[data-selected] { - --hop-SegmentedControlItem-color: var(--hop-neutral-text-selected); - --hop-SegmentedControlItem-background: transparent; + --color: var(--hop-SegmentedControlItem-color-selected); + --background: var(--hop-SegmentedControlItem-background-selected); } .hop-SegmentedControlItem[data-disabled] { - --hop-SegmentedControlItem-color: var(--hop-neutral-text-disabled); - --hop-SegmentedControlItem-background: var(--hop-neutral-surface); + --cursor: var(--hop-SegmentedControlItem-cursor-disabled); + --color: var(--hop-SegmentedControlItem-color-disabled); + --background: var(--hop-SegmentedControlItem-background-disabled); } .hop-SegmentedControlItem--md { - --hop-SegmentedControlItem-padding: var(--hop-SegmentedControlItem-md-padding); + --padding: var(--hop-SegmentedControlItem-md-padding); } .hop-SegmentedControlItem__icon { diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index 4dbaea81..3b119d79 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -106,14 +106,16 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe <Provider values={[ [IconContext, { - size, - zIndex: 1, slots: { [DEFAULT_SLOT]: { - className: styles["hop-SegmentedControlItem__icon"] + className: styles["hop-SegmentedControlItem__icon"], + size, + zIndex: 1 }, "start-icon": { - className: styles["hop-SegmentedControlItem__start-icon"] + className: styles["hop-SegmentedControlItem__start-icon"], + size, + zIndex: 1 } } }], From 1afb768febb4ad2559af1f29ac86bd899bb892b8 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 12:53:16 -0500 Subject: [PATCH 10/17] Change to data-focused-visible --- .../src/SegmentedControl/src/SegmentedControlItem.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css index f16960aa..c63169bc 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css @@ -83,7 +83,7 @@ --background: var(--hop-SegmentedControlItem-background-hovered); } -.hop-SegmentedControlItem[data-focused] { +.hop-SegmentedControlItem[data-focused-visible] { --color: var(--hop-SegmentedControlItem-color-focused); --background: var(--hop-SegmentedControlItem-background-focused); } From f2d93389c841db3123db5578a3dd5943f4970c2f Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 13:54:49 -0500 Subject: [PATCH 11/17] Add pressScale and remove z-index like Spectrum --- .../src/SegmentedControlItem.tsx | 22 ++++++++-------- packages/components/src/utils/src/index.ts | 1 + .../components/src/utils/src/pressScale.ts | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 packages/components/src/utils/src/pressScale.ts diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index 3b119d79..7d62505f 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -1,11 +1,11 @@ import { IconContext } from "@hopper-ui/icons"; import { useStyledSystem, type ResponsiveProp, type StyledComponentProps } from "@hopper-ui/styled-system"; import clsx from "clsx"; -import { forwardRef, useContext, useLayoutEffect, type CSSProperties, type ForwardedRef, type RefObject } from "react"; +import { forwardRef, useContext, useLayoutEffect, useRef, type CSSProperties, type ForwardedRef, type RefObject } from "react"; import { DEFAULT_SLOT, Provider, ToggleButton, ToggleGroupStateContext, useContextProps, type Key } from "react-aria-components"; -import { Text, TextContext } from "../../typography/index.ts"; -import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts"; +import { TextContext } from "../../typography/index.ts"; +import { cssModule, pressScale, type BaseComponentDOMProps } from "../../utils/index.ts"; import { InternalSegmentedControlContext } from "./SegmentedControlContext.ts"; import { SegmentedControlItemContext } from "./SegmentedControlItemContext.ts"; @@ -34,6 +34,7 @@ export interface SegmentedControlItemProps extends Omit<StyledComponentProps<Bas const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRef<HTMLButtonElement>) => { [props, ref] = useContextProps(props, ref, SegmentedControlItemContext); + const divRef = useRef<HTMLDivElement>(null); const { prevRef, currentSelectedRef } = useContext(InternalSegmentedControlContext); const state = useContext(ToggleGroupStateContext); const itemSelected = state?.selectedKeys.has(props.id); @@ -100,33 +101,32 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe style={mergedStyles} slot={slot ?? undefined} > - {({ isSelected }) => ( + {({ isSelected, isDisabled, isPressed }) => ( <> - {isSelected && <div className={styles["hop-SegmentedControlItem__slider"]} ref={currentSelectedRef as RefObject<HTMLDivElement>} />} + {isSelected && !isDisabled && <div className={styles["hop-SegmentedControlItem__slider"]} ref={currentSelectedRef as RefObject<HTMLDivElement>} />} <Provider values={[ [IconContext, { slots: { [DEFAULT_SLOT]: { className: styles["hop-SegmentedControlItem__icon"], - size, - zIndex: 1 + size }, "start-icon": { className: styles["hop-SegmentedControlItem__start-icon"], - size, - zIndex: 1 + size } } }], [TextContext, { size: "sm", - zIndex: 1, className: styles["hop-SegmentedControlItem__text"] }] ]} > - {typeof children === "string" ? <Text>{children}</Text> : children} + <div ref={divRef} style={pressScale(divRef)({ isPressed })} > + {children} + </div> </Provider> </> )} diff --git a/packages/components/src/utils/src/index.ts b/packages/components/src/utils/src/index.ts index 11540888..388c2a25 100644 --- a/packages/components/src/utils/src/index.ts +++ b/packages/components/src/utils/src/index.ts @@ -4,6 +4,7 @@ export * from "./composeClassnameRenderProps.ts"; export * from "./cssModule.ts"; export * from "./EnsureTextWrapper.tsx"; export * from "./isTextOnlyChildren.ts"; +export * from "./pressScale.ts"; export * from "./sizeAdapter.ts"; export * from "./SlotProvider.ts"; export * from "./types.ts"; diff --git a/packages/components/src/utils/src/pressScale.ts b/packages/components/src/utils/src/pressScale.ts new file mode 100644 index 00000000..b9d843e9 --- /dev/null +++ b/packages/components/src/utils/src/pressScale.ts @@ -0,0 +1,25 @@ +/* + * Taken from: https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/s2/src/pressScale.ts + */ + +import type { CSSProperties, RefObject } from "react"; +import { composeRenderProps } from "react-aria-components"; + +export function pressScale<R extends { isPressed: boolean }>(ref: RefObject<HTMLElement | null>, _style?: CSSProperties | ((renderProps: R) => CSSProperties)) { + return composeRenderProps(_style, (style, renderProps: R) => { + if (renderProps.isPressed && ref.current) { + const { width = 0, height = 0 } = ref.current.getBoundingClientRect() ?? {}; + + return { + ...style, + willChange: `${style?.willChange ?? ""} transform`, + transform: `${style?.transform ?? ""} perspective(${Math.max(height, width / 3, 24)}px) translate3d(0, 0, -2px)` + }; + } else { + return { + ...style, + willChange: `${style?.willChange ?? ""} transform` + }; + } + }); +} From 7c8293e4cce058b72ba59a65152a5863bca64920 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 15:05:21 -0500 Subject: [PATCH 12/17] Remove pressScale --- .../src/SegmentedControlItem.module.css | 19 ++++++++++++++ .../src/SegmentedControlItem.tsx | 6 ++--- packages/components/src/utils/src/index.ts | 1 - .../components/src/utils/src/pressScale.ts | 25 ------------------- 4 files changed, 22 insertions(+), 29 deletions(-) delete mode 100644 packages/components/src/utils/src/pressScale.ts diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css index c63169bc..30d79059 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css @@ -10,6 +10,10 @@ --hop-SegmentedControlItem-display: flex; --hop-SegmentedControlItem-align-items: center; --hop-SegmentedControlItem-justify-content: center; + --hop-SegmentedControlItem-transition: color 0.3s; + --hop-SegmentedControlItem-outline: none; + --hop-SegmentedControlItem-outline-offset: var(--hop-space-20); + --hop-SegmentedControlItem-focus-ring-color: var(--hop-primary-border-focus); /* Small */ --hop-SegmentedControlItem-sm-padding: 0.125rem var(--hop-space-inset-md); @@ -49,6 +53,7 @@ --cursor: var(--hop-SegmentedControlItem-cursor); --background: var(--hop-SegmentedControlItem-background); --padding: var(--hop-SegmentedControlItem-sm-padding); + --outline: var(--hop-SegmentedControlItem-outline); cursor: var(--cursor); @@ -64,6 +69,10 @@ color: var(--color); background: var(--background); + outline: var(--outline); + outline-offset: var(--hop-SegmentedControlItem-outline-offset); + + transition: var(--hop-SegmentedControlItem-transition); } .hop-SegmentedControlItem, .hop-SegmentedControlItem__slider { @@ -78,6 +87,15 @@ background: var(--hop-SegmentedControlItem-slider-background); } +.hop-SegmentedControlItem__wrapper { + will-change: transform; + + display: var(--hop-SegmentedControlItem-display); + gap: var(--hop-SegmentedControlItem-gap); + align-items: var(--hop-SegmentedControlItem-align-items); + justify-content: var(--hop-SegmentedControlItem-justify-content); +} + .hop-SegmentedControlItem[data-hovered] { --color: var(--hop-SegmentedControlItem-color-hovered); --background: var(--hop-SegmentedControlItem-background-hovered); @@ -86,6 +104,7 @@ .hop-SegmentedControlItem[data-focused-visible] { --color: var(--hop-SegmentedControlItem-color-focused); --background: var(--hop-SegmentedControlItem-background-focused); + --outline: var(--hop-SegmentedControlItem-outline-offset) solid var(--hop-SegmentedControlItem-focus-ring-color); } .hop-SegmentedControlItem[data-pressed] { diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index 7d62505f..5877e7a6 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -5,7 +5,7 @@ import { forwardRef, useContext, useLayoutEffect, useRef, type CSSProperties, ty import { DEFAULT_SLOT, Provider, ToggleButton, ToggleGroupStateContext, useContextProps, type Key } from "react-aria-components"; import { TextContext } from "../../typography/index.ts"; -import { cssModule, pressScale, type BaseComponentDOMProps } from "../../utils/index.ts"; +import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts"; import { InternalSegmentedControlContext } from "./SegmentedControlContext.ts"; import { SegmentedControlItemContext } from "./SegmentedControlItemContext.ts"; @@ -101,7 +101,7 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe style={mergedStyles} slot={slot ?? undefined} > - {({ isSelected, isDisabled, isPressed }) => ( + {({ isSelected, isDisabled }) => ( <> {isSelected && !isDisabled && <div className={styles["hop-SegmentedControlItem__slider"]} ref={currentSelectedRef as RefObject<HTMLDivElement>} />} <Provider @@ -124,7 +124,7 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe }] ]} > - <div ref={divRef} style={pressScale(divRef)({ isPressed })} > + <div ref={divRef} className={styles["hop-SegmentedControlItem__wrapper"]} > {children} </div> </Provider> diff --git a/packages/components/src/utils/src/index.ts b/packages/components/src/utils/src/index.ts index 388c2a25..11540888 100644 --- a/packages/components/src/utils/src/index.ts +++ b/packages/components/src/utils/src/index.ts @@ -4,7 +4,6 @@ export * from "./composeClassnameRenderProps.ts"; export * from "./cssModule.ts"; export * from "./EnsureTextWrapper.tsx"; export * from "./isTextOnlyChildren.ts"; -export * from "./pressScale.ts"; export * from "./sizeAdapter.ts"; export * from "./SlotProvider.ts"; export * from "./types.ts"; diff --git a/packages/components/src/utils/src/pressScale.ts b/packages/components/src/utils/src/pressScale.ts deleted file mode 100644 index b9d843e9..00000000 --- a/packages/components/src/utils/src/pressScale.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Taken from: https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/s2/src/pressScale.ts - */ - -import type { CSSProperties, RefObject } from "react"; -import { composeRenderProps } from "react-aria-components"; - -export function pressScale<R extends { isPressed: boolean }>(ref: RefObject<HTMLElement | null>, _style?: CSSProperties | ((renderProps: R) => CSSProperties)) { - return composeRenderProps(_style, (style, renderProps: R) => { - if (renderProps.isPressed && ref.current) { - const { width = 0, height = 0 } = ref.current.getBoundingClientRect() ?? {}; - - return { - ...style, - willChange: `${style?.willChange ?? ""} transform`, - transform: `${style?.transform ?? ""} perspective(${Math.max(height, width / 3, 24)}px) translate3d(0, 0, -2px)` - }; - } else { - return { - ...style, - willChange: `${style?.willChange ?? ""} transform` - }; - } - }); -} From 69db9a24e3f7c3bb19e3d0c2242c589ed28431a0 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 16:36:09 -0500 Subject: [PATCH 13/17] Add isJustified --- .../src/SegmentedControl/docs/justified.tsx | 10 +++++----- .../src/SegmentedControl.module.css | 18 ++++++++++++------ .../SegmentedControl/src/SegmentedControl.tsx | 12 +++++++++--- .../src/SegmentedControlContext.ts | 1 + .../src/SegmentedControlItem.module.css | 11 ++++++++++- .../src/SegmentedControlItem.tsx | 5 +++-- .../chromatic/SegmentedControl.stories.tsx | 14 ++++++++++++++ 7 files changed, 54 insertions(+), 17 deletions(-) diff --git a/packages/components/src/SegmentedControl/docs/justified.tsx b/packages/components/src/SegmentedControl/docs/justified.tsx index 5db4cef6..572d8b30 100644 --- a/packages/components/src/SegmentedControl/docs/justified.tsx +++ b/packages/components/src/SegmentedControl/docs/justified.tsx @@ -2,11 +2,11 @@ import { SegmentedControl, SegmentedControlItem } from "@hopper-ui/components"; export default function Example() { return ( - <SegmentedControl width="100%" aria-label="Types of frog"> - <SegmentedControlItem id="common" flex={1}>Common</SegmentedControlItem> - <SegmentedControlItem id="american" flex={1}>American Bullfrog</SegmentedControlItem> - <SegmentedControlItem id="month" flex={1}>Red-Eyed Tree</SegmentedControlItem> - <SegmentedControlItem id="year" flex={1}>Golden Mantella</SegmentedControlItem> + <SegmentedControl isJustified aria-label="Types of frog"> + <SegmentedControlItem id="american">American Bullfrog</SegmentedControlItem> + <SegmentedControlItem id="month">Red-Eyed Tree</SegmentedControlItem> + <SegmentedControlItem id="year">Golden Mantella</SegmentedControlItem> + <SegmentedControlItem id="common">Common</SegmentedControlItem> </SegmentedControl> ); } diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.module.css b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css index ee8af51c..7465b091 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css @@ -1,20 +1,22 @@ .hop-SegmentedControl { --hop-SegmentedControl-gap: var(--hop-space-inline-xs); --hop-SegmentedControl-padding: var(--hop-space-inset-xs); - --hop-SegmentedControl-witdh: fit-content; + --hop-SegmentedControl-width: fit-content; --hop-SegmentedControl-background: var(--hop-neutral-surface); --hop-SegmentedControl-border: 0.0625rem solid var(--hop-neutral-border); --hop-SegmentedControl-border-radius: var(--hop-shape-rounded-md); --hop-SegmentedControl-display: flex; - --hop-SegmentedControl-align-items: center; - --hop-SegmentedControl-justify-content: center; + + /* Justified */ + --hop-SegmentedControl-width-justified: 100%; + + /* Internal variables */ + --width: var(--hop-SegmentedControl-width); display: var(--hop-SegmentedControl-display); gap: var(--hop-SegmentedControl-gap); - align-items: var(--hop-SegmentedControl-align-items); - justify-content: var(--hop-SegmentedControl-justify-content); - inline-size: var(--hop-SegmentedControl-witdh); + inline-size: var(--width); padding: var(--hop-SegmentedControl-padding); background: var(--hop-SegmentedControl-background); @@ -22,4 +24,8 @@ border-radius: var(--hop-SegmentedControl-border-radius); } +.hop-SegmentedControl--justified { + --width: var(--hop-SegmentedControl-width-justified); +} + diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx index 17df031a..2ace7735 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx @@ -18,6 +18,10 @@ export interface SegmentedControlProps extends StyledComponentProps<BaseComponen * Whether the segmented control is disabled. */ isDisabled?: boolean; + /** + * Whether the items should divide the container width equally. + */ + isJustified?: boolean; /** * The id of the currently selected item (controlled). */ @@ -54,6 +58,7 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi selectedKey, onSelectionChange, size, + isJustified, ...otherProps } = ownProps; @@ -61,7 +66,8 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi GlobalSegmentedControlCssSelector, cssModule( styles, - "hop-SegmentedControl" + "hop-SegmentedControl", + isJustified && "justified" ), stylingProps.className, className @@ -111,7 +117,7 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi <Provider values={[ [SegmentedControlItemContext, { size, isDisabled: otherProps.isDisabled }], - [InternalSegmentedControlContext, { prevRef, currentSelectedRef }] + [InternalSegmentedControlContext, { prevRef, currentSelectedRef, isJustified }] ]} > {children} @@ -123,7 +129,7 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi /** * Segmented control displays multiple contextually or conceptually related action or option stacked in a horizontal row. * - * [View Documentation](TODO) + * [View Documentation](https://hopper.workleap.design/components/SegmentedControl) */ const _SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(SegmentedControl); _SegmentedControl.displayName = "SegmentedControl"; diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts b/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts index b5b23e1a..ba2f1ad6 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts +++ b/packages/components/src/SegmentedControl/src/SegmentedControlContext.ts @@ -10,6 +10,7 @@ SegmentedControlContext.displayName = "SegmentedControlContext"; interface InternalSegmentedControlContextProps { prevRef?: MutableRefObject<DOMRect | null>; currentSelectedRef?: RefObject<HTMLDivElement | null>; + isJustified?: boolean; } export const InternalSegmentedControlContext = createContext<InternalSegmentedControlContextProps>({}); diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css index 30d79059..49b2650a 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css @@ -14,6 +14,8 @@ --hop-SegmentedControlItem-outline: none; --hop-SegmentedControlItem-outline-offset: var(--hop-space-20); --hop-SegmentedControlItem-focus-ring-color: var(--hop-primary-border-focus); + --hop-SegmentedControlItem-user-select: none; + --hop-SegmentedControlItem-whitespace: nowrap; /* Small */ --hop-SegmentedControlItem-sm-padding: 0.125rem var(--hop-space-inset-md); @@ -56,6 +58,7 @@ --outline: var(--hop-SegmentedControlItem-outline); cursor: var(--cursor); + user-select: var(--hop-SegmentedControlItem-user-select); position: var(--hop-SegmentedControlItem-position); @@ -67,6 +70,7 @@ padding: var(--padding); color: var(--color); + white-space: var(--hop-SegmentedControlItem-whitespace); background: var(--background); outline: var(--outline); @@ -101,7 +105,7 @@ --background: var(--hop-SegmentedControlItem-background-hovered); } -.hop-SegmentedControlItem[data-focused-visible] { +.hop-SegmentedControlItem[data-focus-visible] { --color: var(--hop-SegmentedControlItem-color-focused); --background: var(--hop-SegmentedControlItem-background-focused); --outline: var(--hop-SegmentedControlItem-outline-offset) solid var(--hop-SegmentedControlItem-focus-ring-color); @@ -138,3 +142,8 @@ .hop-SegmentedControlItem__start-icon { order: 0 } + +.hop-SegmentedControlItem--justified { + flex-basis: 0%; + flex-grow: 1; +} diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index 5877e7a6..320bf69a 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -35,7 +35,7 @@ export interface SegmentedControlItemProps extends Omit<StyledComponentProps<Bas const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRef<HTMLButtonElement>) => { [props, ref] = useContextProps(props, ref, SegmentedControlItemContext); const divRef = useRef<HTMLDivElement>(null); - const { prevRef, currentSelectedRef } = useContext(InternalSegmentedControlContext); + const { prevRef, currentSelectedRef, isJustified } = useContext(InternalSegmentedControlContext); const state = useContext(ToggleGroupStateContext); const itemSelected = state?.selectedKeys.has(props.id); @@ -54,7 +54,8 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe cssModule( styles, "hop-SegmentedControlItem", - size + size, + isJustified && "justified" ), stylingProps.className, className diff --git a/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx b/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx index 950eeab9..13911f61 100644 --- a/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx +++ b/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx @@ -132,6 +132,20 @@ export const OnlyIcons = { } } satisfies Story; +export const Justified = { + render: props => ( + <SegmentedControl isJustified defaultSelectedKey="american" {...props} > + <SegmentedControlItem id="common">Common</SegmentedControlItem> + <SegmentedControlItem id="american">American Bullfrog</SegmentedControlItem> + <SegmentedControlItem id="month">Red-Eyed Tree</SegmentedControlItem> + <SegmentedControlItem id="year">Golden Mantella</SegmentedControlItem> + </SegmentedControl> + ), + args: { + "aria-label": "List organization" + } +} satisfies Story; + export const Disabled = { render: props => ( <Stack> From 5495ec218f99df238368360c524de8b6d700796f Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 16:44:44 -0500 Subject: [PATCH 14/17] Fix justified --- .../src/SegmentedControl/docs/justified.tsx | 10 +++++----- .../SegmentedControl/src/SegmentedControl.module.css | 12 +----------- .../src/SegmentedControl/src/SegmentedControl.tsx | 3 +-- .../src/SegmentedControlItem.module.css | 3 +-- .../tests/chromatic/SegmentedControl.stories.tsx | 10 +++++----- 5 files changed, 13 insertions(+), 25 deletions(-) diff --git a/packages/components/src/SegmentedControl/docs/justified.tsx b/packages/components/src/SegmentedControl/docs/justified.tsx index 572d8b30..cd082256 100644 --- a/packages/components/src/SegmentedControl/docs/justified.tsx +++ b/packages/components/src/SegmentedControl/docs/justified.tsx @@ -2,11 +2,11 @@ import { SegmentedControl, SegmentedControlItem } from "@hopper-ui/components"; export default function Example() { return ( - <SegmentedControl isJustified aria-label="Types of frog"> - <SegmentedControlItem id="american">American Bullfrog</SegmentedControlItem> - <SegmentedControlItem id="month">Red-Eyed Tree</SegmentedControlItem> - <SegmentedControlItem id="year">Golden Mantella</SegmentedControlItem> - <SegmentedControlItem id="common">Common</SegmentedControlItem> + <SegmentedControl UNSAFE_width="400px" isJustified aria-label="Time granularity"> + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> </SegmentedControl> ); } diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.module.css b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css index 7465b091..cd7d71ad 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.module.css @@ -7,16 +7,10 @@ --hop-SegmentedControl-border-radius: var(--hop-shape-rounded-md); --hop-SegmentedControl-display: flex; - /* Justified */ - --hop-SegmentedControl-width-justified: 100%; - - /* Internal variables */ - --width: var(--hop-SegmentedControl-width); - display: var(--hop-SegmentedControl-display); gap: var(--hop-SegmentedControl-gap); - inline-size: var(--width); + inline-size: var(--hop-SegmentedControl-width); padding: var(--hop-SegmentedControl-padding); background: var(--hop-SegmentedControl-background); @@ -24,8 +18,4 @@ border-radius: var(--hop-SegmentedControl-border-radius); } -.hop-SegmentedControl--justified { - --width: var(--hop-SegmentedControl-width-justified); -} - diff --git a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx index 2ace7735..4f79b17d 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControl.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControl.tsx @@ -66,8 +66,7 @@ const SegmentedControl = (props: SegmentedControlProps, ref: ForwardedRef<HTMLDi GlobalSegmentedControlCssSelector, cssModule( styles, - "hop-SegmentedControl", - isJustified && "justified" + "hop-SegmentedControl" ), stylingProps.className, className diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css index 49b2650a..8ba5fd81 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.module.css @@ -144,6 +144,5 @@ } .hop-SegmentedControlItem--justified { - flex-basis: 0%; - flex-grow: 1; + flex: 1; } diff --git a/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx b/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx index 13911f61..d3228a6a 100644 --- a/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx +++ b/packages/components/src/SegmentedControl/tests/chromatic/SegmentedControl.stories.tsx @@ -134,11 +134,11 @@ export const OnlyIcons = { export const Justified = { render: props => ( - <SegmentedControl isJustified defaultSelectedKey="american" {...props} > - <SegmentedControlItem id="common">Common</SegmentedControlItem> - <SegmentedControlItem id="american">American Bullfrog</SegmentedControlItem> - <SegmentedControlItem id="month">Red-Eyed Tree</SegmentedControlItem> - <SegmentedControlItem id="year">Golden Mantella</SegmentedControlItem> + <SegmentedControl UNSAFE_width="400px" isJustified defaultSelectedKey="day" {...props} > + <SegmentedControlItem id="day">Day</SegmentedControlItem> + <SegmentedControlItem id="week">Week</SegmentedControlItem> + <SegmentedControlItem id="month">Month</SegmentedControlItem> + <SegmentedControlItem id="year">Year</SegmentedControlItem> </SegmentedControl> ), args: { From 5d99d234d23e4bff002958c9cd334300ad929bfc Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 16:56:38 -0500 Subject: [PATCH 15/17] Fix Text size --- .../src/SegmentedControl/src/SegmentedControlItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index 320bf69a..b41fe13f 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -4,7 +4,7 @@ import clsx from "clsx"; import { forwardRef, useContext, useLayoutEffect, useRef, type CSSProperties, type ForwardedRef, type RefObject } from "react"; import { DEFAULT_SLOT, Provider, ToggleButton, ToggleGroupStateContext, useContextProps, type Key } from "react-aria-components"; -import { TextContext } from "../../typography/index.ts"; +import { Text, TextContext } from "../../typography/index.ts"; import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts"; import { InternalSegmentedControlContext } from "./SegmentedControlContext.ts"; @@ -126,7 +126,7 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe ]} > <div ref={divRef} className={styles["hop-SegmentedControlItem__wrapper"]} > - {children} + {typeof children === "string" ? <Text>{children}</Text> : children} </div> </Provider> </> From 2be171aa2a40b66c20946e236c4597195ec4ebd5 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 16:58:01 -0500 Subject: [PATCH 16/17] Remove unused divRef --- .../src/SegmentedControl/src/SegmentedControlItem.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index b41fe13f..520d9f9c 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -1,7 +1,7 @@ import { IconContext } from "@hopper-ui/icons"; import { useStyledSystem, type ResponsiveProp, type StyledComponentProps } from "@hopper-ui/styled-system"; import clsx from "clsx"; -import { forwardRef, useContext, useLayoutEffect, useRef, type CSSProperties, type ForwardedRef, type RefObject } from "react"; +import { forwardRef, useContext, useLayoutEffect, type CSSProperties, type ForwardedRef, type RefObject } from "react"; import { DEFAULT_SLOT, Provider, ToggleButton, ToggleGroupStateContext, useContextProps, type Key } from "react-aria-components"; import { Text, TextContext } from "../../typography/index.ts"; @@ -34,7 +34,6 @@ export interface SegmentedControlItemProps extends Omit<StyledComponentProps<Bas const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRef<HTMLButtonElement>) => { [props, ref] = useContextProps(props, ref, SegmentedControlItemContext); - const divRef = useRef<HTMLDivElement>(null); const { prevRef, currentSelectedRef, isJustified } = useContext(InternalSegmentedControlContext); const state = useContext(ToggleGroupStateContext); const itemSelected = state?.selectedKeys.has(props.id); @@ -125,7 +124,7 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe }] ]} > - <div ref={divRef} className={styles["hop-SegmentedControlItem__wrapper"]} > + <div className={styles["hop-SegmentedControlItem__wrapper"]} > {typeof children === "string" ? <Text>{children}</Text> : children} </div> </Provider> From fdf3904d8558cf1134e8dc198e0b13ff52a2cb40 Mon Sep 17 00:00:00 2001 From: Victor Trinh <victor.trinh@gsoft.com> Date: Fri, 10 Jan 2025 16:58:57 -0500 Subject: [PATCH 17/17] Add documentation link for SegmentedControlItem --- .../src/SegmentedControl/src/SegmentedControlItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx index 520d9f9c..1fbe5596 100644 --- a/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx +++ b/packages/components/src/SegmentedControl/src/SegmentedControlItem.tsx @@ -138,7 +138,7 @@ const SegmentedControlItem = (props: SegmentedControlItemProps, ref: ForwardedRe /** * A SegmentedControlItem represents an option within a SegmentedControl. * - * [View Documentation](TODO) + * [View Documentation](https://hopper.workleap.design/components/SegmentedControl) */ const _SegmentedControlItem = forwardRef<HTMLButtonElement, SegmentedControlItemProps>(SegmentedControlItem); _SegmentedControlItem.displayName = "SegmentedControlItem";