Skip to content

Commit 26a8f37

Browse files
mushroomgenieMashrukh Islam
andauthored
feat: add segmented control component (#1401)
Co-authored-by: Mashrukh Islam <mushroomgenie@Iceberg.local>
1 parent d2f2406 commit 26a8f37

6 files changed

Lines changed: 307 additions & 0 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import React from "react";
2+
3+
import { Meta, StoryObj } from "@storybook/react";
4+
import SegmentedControl from "./SegmentedControl";
5+
6+
const meta: Meta<typeof SegmentedControl> = {
7+
component: SegmentedControl,
8+
tags: ["autodocs"],
9+
};
10+
11+
export default meta;
12+
13+
type Story = StoryObj<typeof SegmentedControl>;
14+
15+
export const Default: Story = {
16+
name: "Default",
17+
18+
args: {
19+
segments: [
20+
{
21+
label: "OLM",
22+
content: (
23+
<p>
24+
A system to help you move from configuration management to
25+
application management across your hybrid cloud estate - through
26+
sharable, reusable, tiny applications called Charmed Operators.
27+
</p>
28+
),
29+
},
30+
{
31+
label: "SDK",
32+
content: (
33+
<p>
34+
A set of tools to help you write Charmed Operators and to package
35+
them as Charms.
36+
</p>
37+
),
38+
},
39+
{
40+
label: "Charmhub",
41+
content: (
42+
<p>
43+
A repository for charms - from Observability to Data to Identity and
44+
more.
45+
</p>
46+
),
47+
},
48+
],
49+
},
50+
};
51+
52+
/**
53+
* The buttons take up a more compact apperance by adding the modifier is-dense
54+
*/
55+
56+
export const Dense: Story = {
57+
name: "Dense",
58+
59+
args: {
60+
className: "is-dense",
61+
segments: [
62+
{
63+
label: "OLM",
64+
content: (
65+
<p>
66+
A system to help you move from configuration management to
67+
application management across your hybrid cloud estate - through
68+
sharable, reusable, tiny applications called Charmed Operators.
69+
</p>
70+
),
71+
},
72+
{
73+
label: "SDK",
74+
content: (
75+
<p>
76+
A set of tools to help you write Charmed Operators and to package
77+
them as Charms.
78+
</p>
79+
),
80+
},
81+
{
82+
label: "Charmhub",
83+
content: (
84+
<p>
85+
A repository for charms - from Observability to Data to Identity and
86+
more.
87+
</p>
88+
),
89+
},
90+
],
91+
},
92+
};
93+
/**
94+
* The pattern also supports the use of icons within each button.
95+
*
96+
* 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.
97+
*/
98+
export const WithIcon: Story = {
99+
name: "With Icon",
100+
args: {
101+
segments: [
102+
{
103+
label: "OLM",
104+
content: (
105+
<p>
106+
A system to help you move from configuration management to
107+
application management across your hybrid cloud estate - through
108+
sharable, reusable, tiny applications called Charmed Operators.
109+
</p>
110+
),
111+
iconName: "information",
112+
},
113+
{
114+
label: "SDK",
115+
content: (
116+
<p>
117+
A set of tools to help you write Charmed Operators and to package
118+
them as Charms.
119+
</p>
120+
),
121+
iconName: "information",
122+
},
123+
{
124+
label: "Charmhub",
125+
content: (
126+
<p>
127+
A repository for charms - from Observability to Data to Identity and
128+
more.
129+
</p>
130+
),
131+
iconName: "information",
132+
},
133+
],
134+
},
135+
};
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from "react";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
5+
import SegmentedControl from "./SegmentedControl";
6+
7+
describe("SegmentedControl", () => {
8+
it("renders", () => {
9+
const { container } = render(
10+
<SegmentedControl
11+
segments={[
12+
{
13+
label: "label1",
14+
content: <p>content1</p>,
15+
},
16+
]}
17+
/>,
18+
);
19+
expect(container).toMatchSnapshot();
20+
});
21+
22+
it("can set className correctly", () => {
23+
const { container } = render(
24+
<SegmentedControl
25+
className="is-dense"
26+
segments={[
27+
{
28+
label: "label1",
29+
content: <p>content1</p>,
30+
},
31+
]}
32+
/>,
33+
);
34+
expect(container.querySelector(".p-segmented-control")).toHaveClass(
35+
"is-dense",
36+
);
37+
});
38+
39+
it("can set active segment on segment click", async () => {
40+
render(
41+
<SegmentedControl
42+
segments={[
43+
{
44+
label: "label1",
45+
content: <p>content1</p>,
46+
},
47+
{
48+
label: "label2",
49+
content: <p>content2</p>,
50+
},
51+
]}
52+
/>,
53+
);
54+
const segment = screen.getByRole("tab", { name: "label2" });
55+
await userEvent.click(segment);
56+
expect(screen.getByRole("tab", { name: "label2" })).toHaveAttribute(
57+
"aria-selected",
58+
"true",
59+
);
60+
expect(screen.getByRole("tabpanel", { name: "label2" })).toHaveTextContent(
61+
"content2",
62+
);
63+
});
64+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import classNames from "classnames";
2+
import Icon, { ICONS } from "components/Icon";
3+
import React, { ReactNode, useState } from "react";
4+
import type { ClassName, ValueOf } from "types";
5+
6+
export type Segments = {
7+
/**
8+
* Label to be displayed inside the segment.
9+
*/
10+
label: string;
11+
/**
12+
* Content to be displayed inside the segment.
13+
*/
14+
content: ReactNode;
15+
/**
16+
* Icon to be displayed alongside the label of the segment.
17+
*/
18+
iconName?: ValueOf<typeof ICONS> | string;
19+
};
20+
21+
export type Props = {
22+
/**
23+
* Optional classes applied to the parent element.
24+
*/
25+
className?: ClassName;
26+
/**
27+
* List of segments present in the element.
28+
*/
29+
segments: Segments[];
30+
};
31+
/**
32+
* This is the [React](https://reactjs.org/) component for Vanilla [SegmentedControl](https://vanillaframework.io/docs/patterns/segmented-control).
33+
SegmentedControl organises and allows navigation between groups of content that are related and at the same level
34+
of hierarchy.
35+
*/
36+
const SegmentedControl = ({
37+
className,
38+
segments,
39+
}: Props): React.JSX.Element => {
40+
const [activeIndex, setActiveIndex] = useState<number>(0);
41+
return (
42+
<div className={classNames("p-segmented-control", className)}>
43+
<div className={classNames("p-segmented-control__list")} role="tablist">
44+
{segments.map((segment, i) => {
45+
return (
46+
<button
47+
aria-selected={activeIndex === i}
48+
className={classNames("p-segmented-control__button")}
49+
role="tab"
50+
key={segment.label}
51+
id={segment.label}
52+
onClick={() => setActiveIndex(i)}
53+
>
54+
{segment.iconName ? (
55+
<>
56+
<Icon name={segment.iconName} />
57+
<span>{segment.label}</span>
58+
</>
59+
) : (
60+
segment.label
61+
)}
62+
</button>
63+
);
64+
})}
65+
</div>
66+
<div role="tabpanel" aria-labelledby={segments[activeIndex].label}>
67+
{segments[activeIndex].content}
68+
</div>
69+
</div>
70+
);
71+
};
72+
73+
export default SegmentedControl;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`SegmentedControl renders 1`] = `
4+
<div>
5+
<div
6+
class="p-segmented-control"
7+
>
8+
<div
9+
class="p-segmented-control__list"
10+
role="tablist"
11+
>
12+
<button
13+
aria-selected="true"
14+
class="p-segmented-control__button"
15+
id="label1"
16+
role="tab"
17+
>
18+
label1
19+
</button>
20+
</div>
21+
<div
22+
aria-labelledby="label1"
23+
role="tabpanel"
24+
>
25+
<p>
26+
content1
27+
</p>
28+
</div>
29+
</div>
30+
</div>
31+
`;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from "./SegmentedControl";
2+
export type { Props as SegmentedControlProps } from "./SegmentedControl";

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export { default as ScrollableContainer } from "./components/ScrollableContainer
7979
export { default as ScrollableTable } from "./components/ScrollableTable";
8080
export { default as SearchAndFilter } from "./components/SearchAndFilter";
8181
export { default as SearchBox } from "./components/SearchBox";
82+
export { default as SegmentedControl } from "./components/SegmentedControl";
8283
export { default as Select } from "./components/Select";
8384
export { default as SideNavigation } from "./components/SideNavigation";
8485
export { default as SideNavigationItem } from "./components/SideNavigation/SideNavigationItem";
@@ -194,6 +195,7 @@ export type { ScrollableTableProps } from "./components/ScrollableTable";
194195
export type { ScrollableContainerProps } from "./components/ScrollableContainer";
195196
export type { SearchAndFilterProps } from "./components/SearchAndFilter";
196197
export type { SearchBoxProps } from "./components/SearchBox";
198+
export type { SegmentedControlProps } from "./components/SegmentedControl";
197199
export type { SelectProps } from "./components/Select";
198200
export type { SideNavigationProps } from "./components/SideNavigation";
199201
export type { SideNavigationItemProps } from "./components/SideNavigation/SideNavigationItem";

0 commit comments

Comments
 (0)