Skip to content

Commit

Permalink
Merge pull request #10 from inkonchain/feat/segmented-control
Browse files Browse the repository at this point in the history
feat: segmented control component
  • Loading branch information
fran-ink authored Nov 13, 2024
2 parents 54b55e8 + 2ea4518 commit b6028c9
Show file tree
Hide file tree
Showing 8 changed files with 209 additions and 8 deletions.
9 changes: 4 additions & 5 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Preview, ReactRenderer } from "@storybook/react";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
import { withThemeByClassName } from "@storybook/addon-themes";

import "../src/tailwind.css";

Expand All @@ -13,13 +13,12 @@ const preview: Preview = {
},
},
decorators: [
withThemeByDataAttribute<ReactRenderer>({
withThemeByClassName<ReactRenderer>({
themes: {
light: "",
dark: "dark",
dark: "ink-dark",
},
defaultTheme: "light",
attributeName: "data-theme",
defaultTheme: "ink-light",
}),
],
};
Expand Down
41 changes: 41 additions & 0 deletions src/components/SegmentedControl/SegmentedControl.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { SegmentedControl, SegmentedControlProps } from "./SegmentedControl";

const meta: Meta<SegmentedControlProps<string>> = {
title: "Example/SegmentedControl",
component: SegmentedControl,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
onOptionChange: fn(),
options: [
{
label: "First",
value: "first",
selectedByDefault: true,
},
{
label: "Second",
value: "second",
},
{
label: "Third",
value: "third",
},
],
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Simple: Story = {
args: {},
};

export const DisplayOnBlack: Story = {
args: { displayOn: "black" },
};
106 changes: 106 additions & 0 deletions src/components/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { classNames, variantClassNames } from "../../util/classes";
import { DisplayOnProps } from "../../util/theme";

export interface SegmentedControlProps<T extends string>
extends DisplayOnProps {
options: SegmentedControlOption<T>[];
onOptionChange: (option: SegmentedControlOption<T>, index: number) => void;
}

export interface SegmentedControlOption<T extends string> {
label: React.ReactNode;
value: T;
selectedByDefault?: boolean;
}

export const SegmentedControl = <T extends string>({
options,
onOptionChange,
displayOn = "auto",
}: SegmentedControlProps<T>) => {
const itemsRef = useRef<Array<HTMLButtonElement | null>>([]);
const [selectedOption, setSelectedOption] = useState<T | null>(
options.find((opt) => opt.selectedByDefault)?.value ?? null
);
const selectedIndex = useMemo(
() => options.findIndex((opt) => opt.value === selectedOption),
[options, selectedOption]
);

const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);

const { left, width } = useMemo(() => {
// We need to wait for the component to be mounted before we can get the
// selected element's offsetLeft and offsetWidth.
if (!isMounted) {
return { left: 0, width: 0 };
}
const selectedElement = itemsRef.current[selectedIndex];
return {
left: selectedElement?.offsetLeft || 0,
width: selectedElement?.offsetWidth || 0,
};
}, [itemsRef, selectedIndex, isMounted]);

return (
<div className="ink-relative">
{isMounted && selectedOption && (
<div
className="ink-absolute ink-transition-all ink-duration-200 ink-p-0.5"
style={{
top: 0,
bottom: 0,
left: `${left}px`,
width: `${width}px`,
}}
>
<div
className={classNames(
"ink-w-full ink-h-full ink-rounded-full",
variantClassNames(displayOn, {
auto: "ink-bg-background-light dark:ink-bg-background-dark",
white: "ink-bg-background-light",
black: "ink-bg-background-dark",
})
)}
/>
</div>
)}
<div
className={classNames(
"ink-grid ink-gap-2 ink-grid-flow-col [grid-auto-columns:1fr] ink-text-body-2 ink-font-bold ink-rounded-full",
variantClassNames(displayOn, {
auto: "ink-bg-background-container dark:ink-bg-background-light",
white: "ink-bg-background-container",
black: "ink-bg-background-light",
})
)}
>
{options.map((option, index) => (
<button
className={classNames(
"ink-px-4 ink-py-2 ink-rounded-full ink-relative ink-z-10 ink-transition-colors ink-duration-200",
selectedOption === option.value
? "ink-text-text-default"
: "ink-text-text-on-secondary"
)}
ref={(el) => {
itemsRef.current[index] = el;
}}
key={option.value}
onClick={() => {
setSelectedOption(option.value);
onOptionChange(option, index);
}}
>
{option.label}
</button>
))}
</div>
</div>
);
};
1 change: 1 addition & 0 deletions src/components/SegmentedControl/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./SegmentedControl";
47 changes: 47 additions & 0 deletions src/styles/Colors.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react";
import { classNames } from "../util/classes";

function Colors() {
const colors = [
"ink-bg-primary ink-text-text-on-primary",
"ink-bg-secondary ink-text-text-on-secondary",
"ink-bg-background-container",
"ink-bg-background-light-0",
"ink-bg-background-light-50",
"ink-bg-background-light",
"ink-bg-status-success-bg ink-text-status-success",
"ink-bg-status-error-bg ink-text-status-error",
"ink-bg-status-alert-bg ink-text-status-alert",
];
return (
<div className="ink-flex ink-gap-2 ink-flex-wrap">
{colors.map((color) => (
<div
key={color}
className={classNames(
"ink-px-2 ink-py-1 ink-rounded-full ink-text-[#999]",
color
)}
>
{color}
</div>
))}
</div>
);
}

const meta: Meta = {
title: "Example/Colors",
component: Colors,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Simple: Story = {
args: {},
};
4 changes: 2 additions & 2 deletions src/styles/colors.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
--ink-background-container: color-mix(
in srgb,
var(--ink-background-dark),
transparent 94%
transparent 6%
);
--ink-background-light: rgb(255, 255, 255);
--ink-background-light-50: rgb(255, 255, 255);
--ink-background-light-0: rgb(255, 255, 0);
--ink-background-light-0: rgb(255, 255, 255);

--ink-primary: rgb(113, 50, 245);
--ink-primary-hover: color-mix(in srgb, var(--ink-primary), transparent 10%);
Expand Down
7 changes: 7 additions & 0 deletions src/util/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface DisplayOnProps {
/**
* This prop is used to override the default display. For instance, if it is always displayed in a dark container, use `displayOn="black"`.
* The default value is `auto`, which means the component will adapt based on the theme.
*/
displayOn?: "auto" | "white" | "black";
}
2 changes: 1 addition & 1 deletion tailwind.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const spacing = {

const config = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
darkMode: ["class", '[data-mode="dark"]'],
darkMode: "selector",
prefix: "ink-",
theme: {
gap: spacing,
Expand Down

0 comments on commit b6028c9

Please sign in to comment.