Skip to content

Commit

Permalink
@thunderstore/cyberstorm: add NewTabs
Browse files Browse the repository at this point in the history
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
anttimaki committed Nov 24, 2023
1 parent 15bb83c commit 14d5e2e
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 0 deletions.
54 changes: 54 additions & 0 deletions packages/cyberstorm/src/components/NewTabs/Tabs.module.css
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;
}
148 changes: 148 additions & 0 deletions packages/cyberstorm/src/components/NewTabs/Tabs.tsx
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";
1 change: 1 addition & 0 deletions packages/cyberstorm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type { MenuItemProps } from "./components/MenuItem/";
export { MetaItem, type MetaItemProps } from "./components/MetaItem/MetaItem";
export { MetaInfoItem } from "./components/MetaInfoItem/MetaInfoItem";
export { MetaInfoItemList } from "./components/MetaInfoItemList/MetaInfoItemList";
export { default as NewTabs } from "./components/NewTabs/Tabs";
export { Switch, type SwitchProps } from "./components/Switch/Switch";
export { PackageCard } from "./components/PackageCard/PackageCard";
export {
Expand Down

0 comments on commit 14d5e2e

Please sign in to comment.