Skip to content

Instantly share code, notes, and snippets.

@ryexley
Created July 30, 2024 17:57
Show Gist options
  • Save ryexley/0cbd2aecc4b62f46b26f28aaf9b42fd1 to your computer and use it in GitHub Desktop.
Save ryexley/0cbd2aecc4b62f46b26f28aaf9b42fd1 to your computer and use it in GitHub Desktop.
Radix UI Tabs component with Select component as mobile fallback
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 }
]
.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