-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
@thunderstore/cyberstorm: add NewTabs
Add an improved version Tabs component. The old Tabs component is retained for now to avoid scope creep. Ideally eventually all of the old components will be replaced, the old implementation removed and NewTabs renamed to Tabs. The main motivation for the new implementation is an usecase from PackageDetailLayout. Dapper will initally load only content for the default tab. Additionally it will load flags defining whether other tabs have content. E.g. if the changelog tab has no content, the tab is disabled. If changelog exists, it's loaded with a separate Dapper call. Additional improvements include: - Moving away from "children as props" approach - Ease of use when developer doesn't have to provide the tab buttons and contents separately - Ease of use when the Tabs component will internally handle switching between the tabs - Probably cleaner support for Suspenses Refs TS-1979
- Loading branch information
Showing
3 changed files
with
203 additions
and
0 deletions.
There are no files selected for viewing
54 changes: 54 additions & 0 deletions
54
packages/cyberstorm/src/components/NewTabs/Tabs.module.css
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
.root { | ||
display: flex; | ||
flex-direction: column; | ||
gap: var(--space--32); | ||
} | ||
|
||
.buttons { | ||
display: flex; | ||
overflow: auto; | ||
} | ||
|
||
.button { | ||
display: flex; | ||
flex: none; | ||
flex-direction: row; | ||
gap: var(--space--12); | ||
align-items: center; | ||
justify-content: center; | ||
|
||
padding: var(--space--12) var(--space--16); | ||
border-bottom: 3px solid var(--color-purple--5); | ||
color: var(--tab-color); | ||
background-color: transparent; | ||
|
||
--tab-color: var(--color-text--default); | ||
} | ||
|
||
.button.active { | ||
border-color: var(--tab-color); | ||
|
||
--tab-color: var(--color-green--5); | ||
} | ||
|
||
.button:disabled { | ||
--tab-color: var(--color-border--highlight); | ||
} | ||
|
||
.icon { | ||
color: var(--color-border--highlight); | ||
} | ||
|
||
.button.active .icon { | ||
color: var(--tab-color); | ||
} | ||
|
||
.button:not(.active, :disabled):hover > .icon { | ||
color: var(--color-text--tertiary); | ||
} | ||
|
||
.label { | ||
font-weight: var(--font-weight-bold); | ||
font-size: var(--font-size--l); | ||
line-height: 1.3; | ||
} |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
"use client"; | ||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; | ||
import React, { PropsWithChildren, startTransition, useState } from "react"; | ||
|
||
import styles from "./Tabs.module.css"; | ||
import { Icon } from "../Icon/Icon"; | ||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; | ||
import { classnames } from "../../utils/utils"; | ||
|
||
interface Props extends PropsWithChildren { | ||
/** | ||
* Name prop of the Tab that should be shown by default. | ||
* If omitted, the first Tab acts as the default Tab. | ||
*/ | ||
defaultActive?: string; | ||
} | ||
|
||
/** | ||
* Wrapper components for Tab components. | ||
* | ||
* Validates the passed Tab components and handles switching between | ||
* them. | ||
*/ | ||
const Tabs = (props: Props) => { | ||
const { defaultActive } = props; | ||
|
||
// Sanity checking the child components. | ||
const children = React.Children.toArray(props.children); | ||
const seenNames: string[] = []; | ||
|
||
children.forEach((child) => { | ||
if (!React.isValidElement(child) || child.type !== Tab) { | ||
throw new Error("Tabs component only allows Tab child components"); | ||
} | ||
if (seenNames.includes(child.props.name)) { | ||
throw new Error(`Non-unique Tab.name "${child.props.name}" in Tabs`); | ||
} | ||
seenNames.push(child.props.name); | ||
}); | ||
|
||
if (defaultActive && !seenNames.includes(defaultActive)) { | ||
throw new Error( | ||
`Tabs.defaultActive "${defaultActive}" matches no child Tab.name` | ||
); | ||
} | ||
|
||
const [activeTab, setActiveTab] = useState(defaultActive || seenNames[0]); | ||
|
||
// Throw away the ease-of-use Tab components and render internal | ||
// components instead. | ||
const buttons: JSX.Element[] = []; | ||
const contents: JSX.Element[] = []; | ||
|
||
children.forEach((child) => { | ||
if (React.isValidElement(child)) { | ||
buttons.push( | ||
<InternalTabButton | ||
key={child.props.name} | ||
active={child.props.name === activeTab} | ||
setActive={() => setActiveTab(child.props.name)} | ||
{...child.props} | ||
/> | ||
); | ||
contents.push( | ||
<InternalTabContent | ||
key={child.props.name} | ||
active={child.props.name === activeTab} | ||
> | ||
{child.props.children} | ||
</InternalTabContent> | ||
); | ||
} | ||
}); | ||
|
||
return ( | ||
<div className={styles.root}> | ||
<div className={styles.buttons}>{buttons}</div> | ||
<div>{contents}</div> | ||
</div> | ||
); | ||
}; | ||
|
||
Tabs.displayName = "Tabs"; | ||
|
||
interface TabProps extends PropsWithChildren { | ||
/** Unique identifier for the Tab in the context of Tabs */ | ||
name: string; | ||
/** Text shown on the Tab activator */ | ||
label: string; | ||
/** | ||
* Prevents user from selecting the Tab. | ||
* | ||
* Note that if default Tab is disabled, the contents will be shown | ||
* initially, but user can't reselect the Tab once they leave it. | ||
*/ | ||
disabled?: boolean; | ||
/** Icon shown on the Tab activator */ | ||
icon?: IconDefinition; | ||
} | ||
|
||
const Tab = (props: TabProps) => props && null; | ||
|
||
Tab.displayName = "Tab"; | ||
|
||
export default Object.assign(Tabs, { Tab: Tab }); | ||
|
||
interface InternalTabButtonProps { | ||
label: string; | ||
active: boolean; | ||
setActive: () => void; | ||
disabled?: boolean; | ||
icon?: IconDefinition; | ||
} | ||
|
||
/** Only for internal use by Tabs and thus not exported. */ | ||
const InternalTabButton = (props: InternalTabButtonProps) => { | ||
const { active, disabled = false, icon, label, setActive } = props; | ||
|
||
return ( | ||
<button | ||
type="button" | ||
aria-current={active} | ||
className={classnames(styles.button, active ? styles.active : "")} | ||
disabled={disabled} | ||
onClick={() => startTransition(setActive)} | ||
> | ||
{icon ? ( | ||
<Icon inline wrapperClasses={styles.icon}> | ||
<FontAwesomeIcon icon={icon} /> | ||
</Icon> | ||
) : null} | ||
<span className={styles.label}>{label}</span> | ||
</button> | ||
); | ||
}; | ||
|
||
InternalTabButton.displayName = "InternalTabButton"; | ||
|
||
interface InternalTabContentProps extends PropsWithChildren { | ||
active: boolean; | ||
} | ||
|
||
/** Only for internal use by Tabs and thus not exported. */ | ||
const InternalTabContent = (props: InternalTabContentProps) => { | ||
return props.active ? <>{props.children}</> : <></>; | ||
}; | ||
|
||
InternalTabContent.displayName = "InternalTabContent"; |
This file contains 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