Created
July 30, 2024 17:57
-
-
Save ryexley/0cbd2aecc4b62f46b26f28aaf9b42fd1 to your computer and use it in GitHub Desktop.
Radix UI Tabs component with Select component as mobile fallback
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import cx from "clsx" | |
import { useEffect, useState } from "react" | |
import * as TabsPrimitive from "@radix-ui/react-tabs" | |
import { Icon } from "~/components/icon" | |
import { Select } from "~/components/select" | |
import { isNotEmpty } from "~/util" | |
import styles from "./styles.css" | |
/** | |
* | |
* @param tabConfig | |
* { | |
* selectedTab: "", | |
* tabListAriaLabel: "", | |
* tabs: [ | |
* { | |
* icon: "", | |
* iconOptions: {}, | |
* label: "", | |
* key: "", | |
* content: () => {} | |
* } | |
* ] | |
* } | |
* @returns | |
*/ | |
export function Tabs({ | |
selectedTab: propSelectedTab, | |
tabListAriaLabel = "Select a tab", | |
onChange, | |
tabs, | |
tabClass, | |
tabContentClass, | |
className, | |
}) { | |
const [hydrated, setHydrated] = useState(false) | |
const [selectedTab, setSelectedTab] = useState(propSelectedTab) | |
const { triggers, contentPanels } = tabs.reduce((acc, tab) => { | |
acc.triggers.push({ | |
key: tab.key, | |
icon: tab.icon, | |
iconOptions: tab.iconOptions || {}, | |
label: tab.label, | |
}) | |
acc.contentPanels.push({ | |
key: tab.key, | |
content: tab.content, | |
}) | |
return acc | |
}, { triggers: [], contentPanels: [] }) | |
const mobileTabOptions = tabs.map(tab => { | |
return { | |
value: tab.key, | |
label: tab.label, | |
icon: tab.icon, | |
iconOptions: tab.iconOptions, | |
} | |
}) | |
const onTabChange = value => { | |
setSelectedTab(value) | |
onChange(value) | |
} | |
useEffect(() => { | |
setHydrated(true) | |
}, []) | |
return ( | |
<TabsPrimitive.Root | |
className={cx("tabs", className)} | |
defaultValue={selectedTab} | |
onValueChange={onTabChange}> | |
{hydrated ? ( | |
<Select | |
className="mobile-tab-select" | |
value={selectedTab || mobileTabOptions[0].value} | |
items={mobileTabOptions} | |
itemClass="mobile-tab-select-item" | |
onSelect={onTabChange} /> | |
) : null} | |
<TabsPrimitive.List className="tabs-container" aria-label={tabListAriaLabel}> | |
{triggers.map(tab => { | |
return ( | |
<TabsPrimitive.Trigger | |
className={cx("tab", tabClass)} | |
value={tab.key} | |
key={`tab-trigger-${tab.key}`}> | |
{isNotEmpty(tab.icon) ? ( | |
<Icon name={tab.icon} {...tab.iconOptions} className="tab-icon" /> | |
) : null} | |
<span>{tab.label}</span> | |
</TabsPrimitive.Trigger> | |
) | |
})} | |
</TabsPrimitive.List> | |
{contentPanels.map(({ key, content: Content = () => {} }) => { | |
return ( | |
<TabsPrimitive.Content | |
className={cx("tab-content-panel", tabContentClass)} | |
value={key} | |
key={`tab-content-panel-${key}`}> | |
<Content /> | |
</TabsPrimitive.Content> | |
) | |
})} | |
</TabsPrimitive.Root> | |
) | |
} | |
Tabs.links = () => [ | |
...Select.links(), | |
{ rel: "stylesheet", href: styles } | |
] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.tabs { | |
display: flex; | |
flex-direction: column; | |
} | |
.tabs .tabs-container { | |
border-bottom: 0.0625rem solid var(--colors-mono-04); | |
display: none; | |
flex-shrink: 0; | |
} | |
.tabs .tabs-container .tab { | |
align-items: center; | |
background-color: var(--colors-mono-02); | |
border: none; | |
border-bottom: 0.125rem solid transparent; | |
border-right: 0.0625rem solid var(--colors-mono-01); | |
color: var(--colors-mono-10); | |
cursor: pointer; | |
display: flex; | |
flex-grow: 1; | |
font-family: inherit; | |
gap: 0.5rem; | |
justify-content: center; | |
padding: 0.5rem 1rem; | |
text-transform: uppercase; | |
transition: all 250ms ease-in-out; | |
user-select: none; | |
} | |
@media (min-width: 34rem) { | |
.tabs .tabs-container .tab { | |
flex-grow: 0; | |
} | |
} | |
.tabs .tabs-container .tab:first-child { | |
border-top-left-radius: 0.5rem; | |
} | |
.tabs .tabs-container .tab:first-child[data-state="active"] { | |
border-right: 0.0625rem solid var(--colors-mono-01); | |
} | |
.tabs .tabs-container .tab:last-child { | |
border-right: none; | |
border-top-right-radius: 0.5rem; | |
} | |
.tabs .tabs-container .tab:hover { | |
background-color: var(--colors-mono-02); | |
border-bottom: 0.125rem solid var(--colors-links); | |
box-shadow: none; | |
color: var(--colors-links); | |
} | |
.tabs .tabs-container .tab[data-state="active"] { | |
background-color: var(--colors-mono-02); | |
border-bottom: 0.125rem solid currentColor; | |
/* box-shadow: inset 0 -0.0625rem 0 0 currentColor, 0 0.0625rem 0 0 currentColor; */ | |
color: var(--colors-links); | |
} | |
.tabs .tabs-container .tab[data-state="active"] .icon { | |
color: var(--colors-links); | |
} | |
.tabs .tabs-container .tab:focus, | |
.tabs .tabs-container .tab:active { | |
border: none; | |
border-bottom: 0.125rem solid var(--colors-links); | |
/* box-shadow: 0 0 0.25rem var(--colors-neon-green); */ | |
box-shadow: none; | |
outline: none; | |
position: relative; | |
} | |
.tabs .tabs-container .tab .tab-icon { | |
font-size: 1.25rem; | |
} | |
.tab-content-panel { | |
flex-grow: 1; | |
transition: all 250ms ease-in-out; | |
outline: none; | |
padding: 1rem; | |
} | |
.tab-content-panel:focus { | |
box-shadow: none; /* 0 0 0.25rem var(--colors-mono-07); */ | |
} | |
.tabs .mobile-tab-select { | |
margin: 1rem 0; | |
} | |
.select-component.mobile-tab-select { | |
display: flex; | |
} | |
.select-component.mobile-tab-select .select-component.select-trigger { | |
background: var(--colors-mono-02); | |
color: var(--colors-links); | |
} | |
.select-component.mobile-tab-select .select-component.select-trigger-content { | |
font-size: 1.25rem; | |
gap: 1rem; | |
padding: 0.25rem 0.5rem; | |
z-index: 10; | |
} | |
.select-component.mobile-tab-select .select-component.select-trigger-content .icon { | |
color: var(--colors-links); | |
} | |
.select-component.mobile-tab-select .select-component.select-trigger-content > span { | |
align-items: center; | |
display: flex; | |
} | |
.select-component.select-item.mobile-tab-select-item { | |
font-size: 1.25rem; | |
gap: 1rem; | |
padding: 1rem; | |
} | |
.select-component.select-item.mobile-tab-select-item .select-component.select-item-content { | |
gap: 1rem; | |
} | |
.select-component.select-item.mobile-tab-select-item .select-component.select-item-indicator { | |
right: 1rem; | |
} | |
@media (min-width: 40rem) { | |
.tabs .tabs-container { | |
display: flex; | |
} | |
.select-component.mobile-tab-select { | |
display: none; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment