Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DS-219] Add SegmentedControl component #566

Merged
merged 19 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cold-ways-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"docs": patch
"@hopper-ui/components": patch
---

Added SegmentedControl component
2 changes: 1 addition & 1 deletion apps/docs/components/themeSwitch/ThemeSwitch.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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";
Expand Down
54 changes: 54 additions & 0 deletions apps/docs/content/components/buttons/SegmentedControl.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
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 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" />

### 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" />
victortrinh2 marked this conversation as resolved.
Show resolved Hide resolved

### Icon
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" />
victortrinh2 marked this conversation as resolved.
Show resolved Hide resolved

### 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.

<Example src="SegmentedControl/docs/controlled" />

## Props

### SegmentedControl

<PropTable component="SegmentedControl" />

### SegmentedControlItem

<PropTable component="SegmentedControlItem" />
21 changes: 21 additions & 0 deletions apps/docs/examples/Preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ 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/justified": {
component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/justified.tsx"))
},
"SegmentedControl/docs/controlled": {
victortrinh2 marked this conversation as resolved.
Show resolved Hide resolved
component: lazy(() => import("@/../../packages/components/src/SegmentedControl/docs/controlled.tsx"))
},
"ListBox/docs/preview": {
component: lazy(() => import("@/../../packages/components/src/ListBox/docs/preview.tsx"))
},
Expand Down
27 changes: 27 additions & 0 deletions packages/components/src/SegmentedControl/docs/controlled.tsx
Original file line number Diff line number Diff line change
@@ -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>("day");

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>
);
}
17 changes: 17 additions & 0 deletions packages/components/src/SegmentedControl/docs/icon.tsx
Original file line number Diff line number Diff line change
@@ -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 slot="start-icon" />
<Text>Unordered</Text>
</SegmentedControlItem>
<SegmentedControlItem id="ordered">
<Text>Ordered</Text>
<OrderedListIcon />
</SegmentedControlItem>
</SegmentedControl>
);
}
15 changes: 15 additions & 0 deletions packages/components/src/SegmentedControl/docs/iconOnly.tsx
Original file line number Diff line number Diff line change
@@ -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 aria-label="unordered" />
</SegmentedControlItem>
<SegmentedControlItem id="ordered">
<OrderedListIcon aria-label="ordered" />
</SegmentedControlItem>
</SegmentedControl>
);
}
12 changes: 12 additions & 0 deletions packages/components/src/SegmentedControl/docs/justified.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { SegmentedControl, SegmentedControlItem } from "@hopper-ui/components";

export default function Example() {
return (
<SegmentedControl width="100%" aria-label="Types of frog">
victortrinh2 marked this conversation as resolved.
Show resolved Hide resolved
<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>
);
}
12 changes: 12 additions & 0 deletions packages/components/src/SegmentedControl/docs/preview.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
12 changes: 12 additions & 0 deletions packages/components/src/SegmentedControl/docs/selected.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
12 changes: 12 additions & 0 deletions packages/components/src/SegmentedControl/docs/size.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
1 change: 1 addition & 0 deletions packages/components/src/SegmentedControl/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./src/index.ts";
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.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);
--hop-SegmentedControl-display: flex;
--hop-SegmentedControl-align-items: center;
--hop-SegmentedControl-justify-content: center;

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);
padding: var(--hop-SegmentedControl-padding);

background: var(--hop-SegmentedControl-background);
border: var(--hop-SegmentedControl-border);
border-radius: var(--hop-SegmentedControl-border-radius);
}


131 changes: 131 additions & 0 deletions packages/components/src/SegmentedControl/src/SegmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useStyledSystem, type ResponsiveProp, type StyledComponentProps } from "@hopper-ui/styled-system";
import clsx from "clsx";
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 { InternalSegmentedControlContext, 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 state = useContext(ToggleGroupStateContext);

const prevRef = useRef<DOMRect | null>(null);
const currentSelectedRef = useRef<HTMLDivElement>(null);

const { stylingProps, ...ownProps } = useStyledSystem(props);
const {
className,
children,
style,
slot,
defaultSelectedKey,
selectedKey,
onSelectionChange,
size,
...otherProps
} = ownProps;

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;
}

if (currentSelectedRef.current) {
prevRef.current = currentSelectedRef.current.getBoundingClientRect();
}

onSelectionChange?.(firstKey);
};

useEffect(() => {
const key = defaultSelectedKey ?? selectedKey;

if (key) {
state?.toggleKey(key);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<ToggleButtonGroup
ref={ref}
className={classNames}
style={mergedStyles}
slot={slot ?? undefined}
orientation="horizontal"
onSelectionChange={onChange}
selectedKeys={selectedKey != null ? [selectedKey] : undefined}
defaultSelectedKeys={defaultSelectedKey != null ? [defaultSelectedKey] : undefined}
disallowEmptySelection
{...otherProps}
>
<Provider
values={[
[SegmentedControlItemContext, { size, isDisabled: otherProps.isDisabled }],
[InternalSegmentedControlContext, { prevRef, currentSelectedRef }]
]}
>
{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 };
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext, type MutableRefObject, type RefObject } 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";

interface InternalSegmentedControlContextProps {
prevRef?: MutableRefObject<DOMRect | null>;
currentSelectedRef?: RefObject<HTMLDivElement | null>;
}

export const InternalSegmentedControlContext = createContext<InternalSegmentedControlContextProps>({});
Loading
Loading