diff --git a/packages/react/components/Tabs.tsx b/packages/react/components/Tabs.tsx new file mode 100644 index 0000000..4da625f --- /dev/null +++ b/packages/react/components/Tabs.tsx @@ -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>] +>([ + { + 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({ + tabs: [], + active: [0, defaultName], + }); + + return {children}; +}; + +/** + * 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, + React.HTMLAttributes {} + +const TabList = (props: TabListProps) => { + const [variantProps, restProps] = resolveTabListVariantProps(props); + + return
; +}; + +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, + React.HTMLAttributes, + 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 ( + + 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, "active"> { + name: string; + children: Exclude< + React.ReactNode, + string | number | boolean | Iterable | null | undefined + >; +} + +const TabContent = (props: TabContentProps) => { + const [variantProps, restPropsBeforeParse] = + resolveTabContentVariantProps(props); + const { name, ...restProps } = restPropsBeforeParse; + const [context] = React.useContext(TabContext); + + return ( + + ); +}; + +export { TabProvider, useTabState, TabList, TabTrigger, TabContent }; diff --git a/packages/react/stories/Tabs.stories.tsx b/packages/react/stories/Tabs.stories.tsx new file mode 100644 index 0000000..f51b520 --- /dev/null +++ b/packages/react/stories/Tabs.stories.tsx @@ -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) => {Story()}, + ], + parameters: { + layout: "centered", + }, +}; + +export const Default = () => { + return ( + <> +
+ + Tab 1 + Tab 2 + + +
Tab 1 Content
+
+ +
Tab 2 Content
+
+
+ + ); +}; + +export const AsChild = () => { + return ( + <> +
+ + + + + + + + + +
Tab 1 Content
+
+ +
Tab 2 Content
+
+
+ + ); +};