feat: add Tabs

This commit is contained in:
p-sw 2024-05-26 20:05:50 +09:00
parent 30a7e68609
commit 22060ce967
2 changed files with 293 additions and 0 deletions

View File

@ -0,0 +1,230 @@
import { AsChild, Slot, VariantProps, vcn } from "../shared";
import React from "react";
interface Tab {
name: string;
}
interface TabContextBody {
tabs: Tab[];
active: [number, string] /* index, name */;
}
const TabContext = React.createContext<
[TabContextBody, React.Dispatch<React.SetStateAction<TabContextBody>>]
>([
{
tabs: [],
active: [0, ""],
},
() => {
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
console.warn(
"It seems like you're using TabContext outside of provider."
);
}
},
]);
interface TabProviderProps {
defaultName: string;
children: React.ReactNode;
}
const TabProvider = ({ defaultName, children }: TabProviderProps) => {
const state = React.useState<TabContextBody>({
tabs: [],
active: [0, defaultName],
});
return <TabContext.Provider value={state}>{children}</TabContext.Provider>;
};
/**
* Provides current state for tab, using context.
* Also provides functions to control state.
*/
const useTabState = () => {
const [state, setState] = React.useContext(TabContext);
function getActiveTab() {
return state.active;
}
function setActiveTab(name: string): void;
function setActiveTab(index: number): void;
function setActiveTab(param: string | number) {
if (typeof param === "number") {
if (param < 0 || param >= state.tabs.length) {
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
console.error(
`Invalid index passed to setActiveTab: ${param}, valid indices are 0 to ${state.tabs.length - 1}`
);
}
return;
}
setState((prev) => {
return {
...prev,
active: [param, prev.tabs[param].name],
};
});
} else if (typeof param === "string") {
const index = state.tabs.findIndex((tab) => tab.name === param);
if (index === -1) {
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
console.error(
`Invalid name passed to setActiveTab: ${param}, valid names are ${state.tabs.map((tab) => tab.name).join(", ")}`
);
}
return;
}
setActiveTab(index);
}
}
function setPreviousActive() {
if (state.active[0] === 0) {
return;
}
setActiveTab(state.active[0] - 1);
}
function setNextActive() {
if (state.active[0] === state.tabs.length - 1) {
return;
}
setActiveTab(state.active[0] + 1);
}
return {
getActiveTab,
setActiveTab,
setPreviousActive,
setNextActive,
};
};
const [TabListVariant, resolveTabListVariantProps] = vcn({
base: "flex flex-row bg-gray-100 dark:bg-neutral-800 rounded-lg p-1.5 gap-1",
variants: {},
defaults: {},
});
interface TabListProps
extends VariantProps<typeof TabListVariant>,
React.HTMLAttributes<HTMLDivElement> {}
const TabList = (props: TabListProps) => {
const [variantProps, restProps] = resolveTabListVariantProps(props);
return <div className={TabListVariant(variantProps)} {...restProps} />;
};
const [TabTriggerVariant, resolveTabTriggerVariantProps] = vcn({
base: "py-1.5 rounded-md flex-grow transition-all text-sm",
variants: {
active: {
true: "bg-white/100 dark:bg-black/100 text-black dark:text-white",
false:
"bg-white/0 dark:bg-black/0 text-black dark:text-white hover:bg-white/50 dark:hover:bg-black/50",
},
},
defaults: {
active: false,
},
});
interface TabTriggerProps
extends VariantProps<typeof TabTriggerVariant>,
React.HTMLAttributes<HTMLButtonElement>,
Tab,
AsChild {}
const TabTrigger = (props: TabTriggerProps) => {
const [variantProps, restPropsBeforeParse] =
resolveTabTriggerVariantProps(props);
const { name, ...restProps } = restPropsBeforeParse;
const [context, setContext] = React.useContext(TabContext);
React.useEffect(() => {
setContext((prev) => {
return {
...prev,
tabs: [...prev.tabs, { name }],
};
});
return () => {
setContext((prev) => {
return {
...prev,
tabs: prev.tabs.filter((tab) => tab.name !== name),
};
});
};
}, [name]);
const Comp = props.asChild ? Slot : "button";
return (
<Comp
className={TabTriggerVariant({
...variantProps,
active: context.active[1] === name,
})}
onClick={() =>
setContext((prev) => {
return {
...prev,
active: [prev.tabs.findIndex((tab) => tab.name === name), name],
};
})
}
{...restProps}
/>
);
};
const [tabContentVariant, resolveTabContentVariantProps] = vcn({
base: "",
variants: {
active: {
true: "",
false: "hidden",
},
},
defaults: {
active: false,
},
});
interface TabContentProps
extends Omit<VariantProps<typeof tabContentVariant>, "active"> {
name: string;
children: Exclude<
React.ReactNode,
string | number | boolean | Iterable<React.ReactNode> | null | undefined
>;
}
const TabContent = (props: TabContentProps) => {
const [variantProps, restPropsBeforeParse] =
resolveTabContentVariantProps(props);
const { name, ...restProps } = restPropsBeforeParse;
const [context] = React.useContext(TabContext);
return (
<Slot
className={tabContentVariant({
...variantProps,
active: context.active[1] === name,
})}
{...restProps}
/>
);
};
export { TabProvider, useTabState, TabList, TabTrigger, TabContent };

View File

@ -0,0 +1,63 @@
import { Button } from "../components/Button";
import {
TabContent,
TabList,
TabProvider,
TabTrigger,
} from "../components/Tabs";
export default {
title: "React/Tabs",
decorators: [
(Story: any) => <TabProvider defaultName="tab1">{Story()}</TabProvider>,
],
parameters: {
layout: "centered",
},
};
export const Default = () => {
return (
<>
<div className="flex flex-col gap-4 w-[500px]">
<TabList>
<TabTrigger name="tab1">Tab 1</TabTrigger>
<TabTrigger name="tab2">Tab 2</TabTrigger>
</TabList>
<TabContent name="tab1">
<div className="rounded-md bg-neutral-700 p-4">Tab 1 Content</div>
</TabContent>
<TabContent name="tab2">
<div className="rounded-md bg-neutral-700 p-4">Tab 2 Content</div>
</TabContent>
</div>
</>
);
};
export const AsChild = () => {
return (
<>
<div className="flex flex-col gap-4 w-[500px]">
<TabList>
<TabTrigger name="tab1" asChild>
<Button preset="ghost" className="justify-center">
Tab 1
</Button>
</TabTrigger>
<TabTrigger name="tab2" asChild>
<Button preset="ghost" className="justify-center">
Tab 2
</Button>
</TabTrigger>
</TabList>
<TabContent name="tab1">
<div className="rounded-md bg-neutral-700 p-4">Tab 1 Content</div>
</TabContent>
<TabContent name="tab2">
<div className="rounded-md bg-neutral-700 p-4">Tab 2 Content</div>
</TabContent>
</div>
</>
);
};