From 1259346c6a1eb16d3662c88786af36d9c7d5878b Mon Sep 17 00:00:00 2001 From: Mashrukh Islam Date: Sat, 18 Jul 2026 21:08:59 +0530 Subject: [PATCH] feat: add segmented control component --- .../SegmentedControl.stories.tsx | 135 ++++++++++++++++++ .../SegmentedControl.test.tsx | 60 ++++++++ .../SegmentedControl/SegmentedControl.tsx | 74 ++++++++++ .../SegmentedControl.test.tsx.snap | 32 +++++ src/components/SegmentedControl/index.ts | 2 + src/index.ts | 2 + 6 files changed, 305 insertions(+) create mode 100644 src/components/SegmentedControl/SegmentedControl.stories.tsx create mode 100644 src/components/SegmentedControl/SegmentedControl.test.tsx create mode 100644 src/components/SegmentedControl/SegmentedControl.tsx create mode 100644 src/components/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap create mode 100644 src/components/SegmentedControl/index.ts diff --git a/src/components/SegmentedControl/SegmentedControl.stories.tsx b/src/components/SegmentedControl/SegmentedControl.stories.tsx new file mode 100644 index 000000000..dc2c28546 --- /dev/null +++ b/src/components/SegmentedControl/SegmentedControl.stories.tsx @@ -0,0 +1,135 @@ +import React from "react"; + +import { Meta, StoryObj } from "@storybook/react"; +import SegmentedControl from "./SegmentedControl"; + +const meta: Meta = { + component: SegmentedControl, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: "Default", + + args: { + segments: [ + { + label: "OLM", + segmentContent: ( +

+ A system to help you move from configuration management to + application management across your hybrid cloud estate - through + sharable, reusable, tiny applications called Charmed Operators. +

+ ), + }, + { + label: "SDK", + segmentContent: ( +

+ A set of tools to help you write Charmed Operators and to package + them as Charms. +

+ ), + }, + { + label: "Charmhub", + segmentContent: ( +

+ A repository for charms - from Observability to Data to Identity and + more. +

+ ), + }, + ], + }, +}; + +/** + * The buttons take up a more compact apperance by adding the modifier is-dense + */ + +export const Dense: Story = { + name: "Dense", + + args: { + className: "is-dense", + segments: [ + { + label: "OLM", + segmentContent: ( +

+ A system to help you move from configuration management to + application management across your hybrid cloud estate - through + sharable, reusable, tiny applications called Charmed Operators. +

+ ), + }, + { + label: "SDK", + segmentContent: ( +

+ A set of tools to help you write Charmed Operators and to package + them as Charms. +

+ ), + }, + { + label: "Charmhub", + segmentContent: ( +

+ A repository for charms - from Observability to Data to Identity and + more. +

+ ), + }, + ], + }, +}; +/** + * The pattern also supports the use of icons within each button. + * + * Icons : If any icons are used, all buttons within the pattern should have an icon, to avoid any potential confusion that could arise from a mix of buttons with and without an icon. + */ +export const WithIcon: Story = { + name: "With Icon", + args: { + segments: [ + { + label: "OLM", + segmentContent: ( +

+ A system to help you move from configuration management to + application management across your hybrid cloud estate - through + sharable, reusable, tiny applications called Charmed Operators. +

+ ), + segmentIcon: , + }, + { + label: "SDK", + segmentContent: ( +

+ A set of tools to help you write Charmed Operators and to package + them as Charms. +

+ ), + segmentIcon: , + }, + { + label: "Charmhub", + segmentContent: ( +

+ A repository for charms - from Observability to Data to Identity and + more. +

+ ), + segmentIcon: , + }, + ], + }, +}; diff --git a/src/components/SegmentedControl/SegmentedControl.test.tsx b/src/components/SegmentedControl/SegmentedControl.test.tsx new file mode 100644 index 000000000..eb2b25ba6 --- /dev/null +++ b/src/components/SegmentedControl/SegmentedControl.test.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import SegmentedControl from "./SegmentedControl"; + +describe("SegmentedControl", () => { + it("renders", () => { + const { container } = render( + content1

, + }, + ]} + />, + ); + expect(container).toMatchSnapshot(); + }); + + it("can set className correctly", () => { + render( + content1

, + }, + ]} + />, + ); + expect(screen.getByTestId("segmented-control-div")).toHaveClass("is-dense"); + }); + + it("can set active segment on segment click", async () => { + render( + content1

, + }, + { + label: "label2", + segmentContent:

content2

, + }, + ]} + />, + ); + const segment = screen.getByRole("tab", { name: "label2" }); + await userEvent.click(segment); + expect(screen.getByRole("tab", { name: "label2" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByRole("tabpanel")).toHaveTextContent("content2"); + }); +}); diff --git a/src/components/SegmentedControl/SegmentedControl.tsx b/src/components/SegmentedControl/SegmentedControl.tsx new file mode 100644 index 000000000..2f1c4e730 --- /dev/null +++ b/src/components/SegmentedControl/SegmentedControl.tsx @@ -0,0 +1,74 @@ +import classNames from "classnames"; +import React, { ReactNode, HTMLProps, useState } from "react"; +import type { ClassName } from "types"; + +export type Segments

= { + /** + * Label to be displayed inside the segment. + */ + label: ReactNode; + /** + * Content to be displayed inside the segment. + */ + segmentContent: ReactNode; + /** + * Icon to be displayed alongside the label of the segment. + */ + segmentIcon?: ReactNode; +} & (HTMLProps | P); + +export type Props

= { + /** + * Optional classes applied to the parent element. + */ + className?: ClassName; + /** + * List of segments present in the element. + */ + segments: Segments

[]; +}; +/** + * This is the [React](https://reactjs.org/) component for Vanilla [SegmentedControl](https://vanillaframework.io/docs/patterns/segmented-control). +SegmentedControl organises and allows navigation between groups of content that are related and at the same level +of hierarchy. + */ +const SegmentedControl = ({ + className, + segments, +}: Props): React.JSX.Element => { + const [activeIndex, setActiveIndex] = useState(0); + return ( +

+
+ {segments.map((segment, i) => { + return ( + + ); + })} +
+
+ {segments[activeIndex].segmentContent} +
+
+ ); +}; + +export default SegmentedControl; diff --git a/src/components/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap b/src/components/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap new file mode 100644 index 000000000..5c85170c7 --- /dev/null +++ b/src/components/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SegmentedControl renders 1`] = ` +
+
+
+ +
+
+

+ content1 +

+
+
+
+`; diff --git a/src/components/SegmentedControl/index.ts b/src/components/SegmentedControl/index.ts new file mode 100644 index 000000000..dcaee910d --- /dev/null +++ b/src/components/SegmentedControl/index.ts @@ -0,0 +1,2 @@ +export { default } from "./SegmentedControl"; +export type { Props as SegmentedControlProps } from "./SegmentedControl"; diff --git a/src/index.ts b/src/index.ts index 50d9ec989..eb9b08654 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,7 @@ export { default as ScrollableContainer } from "./components/ScrollableContainer export { default as ScrollableTable } from "./components/ScrollableTable"; export { default as SearchAndFilter } from "./components/SearchAndFilter"; export { default as SearchBox } from "./components/SearchBox"; +export { default as SegmentedControl } from "./components/SegmentedControl"; export { default as Select } from "./components/Select"; export { default as SideNavigation } from "./components/SideNavigation"; export { default as SideNavigationItem } from "./components/SideNavigation/SideNavigationItem"; @@ -194,6 +195,7 @@ export type { ScrollableTableProps } from "./components/ScrollableTable"; export type { ScrollableContainerProps } from "./components/ScrollableContainer"; export type { SearchAndFilterProps } from "./components/SearchAndFilter"; export type { SearchBoxProps } from "./components/SearchBox"; +export type { SegmentedControlProps } from "./components/SegmentedControl"; export type { SelectProps } from "./components/Select"; export type { SideNavigationProps } from "./components/SideNavigation"; export type { SideNavigationItemProps } from "./components/SideNavigation/SideNavigationItem";