diff --git a/src/components/Popover/Popover.tsx b/src/components/Popover/Popover.tsx index c9d4b7415..b1e8bd5c7 100644 --- a/src/components/Popover/Popover.tsx +++ b/src/components/Popover/Popover.tsx @@ -4,7 +4,7 @@ import { useState, createContext, useContext } from 'react'; import React from 'react'; import { createPortal } from 'react-dom'; import { usePopper } from 'react-popper'; -import type { ExtractProps } from '../../util/utility-types'; +import type { ExtractProps, RenderProps } from '../../util/utility-types'; import type { PopoverOptions, PopoverContext as PopoverContextType, @@ -76,10 +76,6 @@ const PopoverGroup = (props: ExtractProps) => ( ); -type RenderProps = { - children: React.ReactNode | ((args: RenderPropArgs) => React.ReactElement); -}; - type PopoverButtonProps = { as?: React.ElementType; className?: string; diff --git a/src/components/Tab/Tab.tsx b/src/components/Tab/Tab.tsx index fe6ea34a3..e66c489f7 100644 --- a/src/components/Tab/Tab.tsx +++ b/src/components/Tab/Tab.tsx @@ -1,9 +1,15 @@ import type { ReactNode } from 'react'; + import React from 'react'; +import { withoutTypes } from 'react-children-by-type'; + +import type { RenderProps } from '../../util/utility-types'; -export interface Props { +import { type TabContextArgs } from '../Tabs/Tabs'; + +export interface TabProps { /** - * aria-labelledby attribute that associates a tab panel with its accompanying tab title text + * aria-labelledby attribute that associates a tab panel with its accompanying tab title */ 'aria-labelledby'?: string; /** @@ -28,21 +34,24 @@ export interface Props { title: string; } +type TabButtonProps = RenderProps; + /** * `import {Tab} from "@chanzuckerberg/eds";` * * Individual tab within the Tabs component. + * - Use the `title` prop for text-only tab headers + * - For more custom tab headers use `` which uses a render prop with state information */ export const Tab = ({ children, className, id, // Destructure `title` so it is not applied to the rendered element - title, 'aria-labelledby': ariaLabelledBy, ...other -}: Props) => { +}: TabProps) => { return (
- {children} + {withoutTypes(children, TabButton)}
); }; + +/** + * This component is a stub, and exists to give a type to the custom Tab button. + * It cannot be used as a standalone sub-component. See for where we trigger the render prop. + */ +const TabButton = (props: TabButtonProps) => { + return
; +}; + +/** + * Allows for control of the Tab Title contents, for custom tab handling + * + * ```jsx + * + * {({active, title}) => ( + * + * )} + * + * ``` + */ +Tab.Button = TabButton; diff --git a/src/components/Tab/index.ts b/src/components/Tab/index.ts index f2515a00e..9be1f73ff 100644 --- a/src/components/Tab/index.ts +++ b/src/components/Tab/index.ts @@ -1,2 +1,2 @@ export { Tab as default } from './Tab'; -export type { Props as TabProps } from './Tab'; +export type { TabProps } from './Tab'; diff --git a/src/components/Tabs/Tabs.stories.tsx b/src/components/Tabs/Tabs.stories.tsx index da8385bfa..4797ea50d 100644 --- a/src/components/Tabs/Tabs.stories.tsx +++ b/src/components/Tabs/Tabs.stories.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { Tabs } from './Tabs'; import { chromaticViewports } from '../../util/viewports'; import Heading from '../Heading'; +import Icon, { type IconName } from '../Icon'; import Tab from '../Tab'; import Text from '../Text'; @@ -194,3 +195,314 @@ export const ScrollMiddle: StoryObj = { ), ], }; + +const IconTab = ({ + isActive, + title, + icon, +}: { + isActive: boolean; + title: string; + icon: IconName; +}) => ( +
+
+ +
+ {`${isActive ? '●' : '◦'} ${title}`} +
+); + +export const CustomTabs: StoryObj = { + decorators: [(Story) =>
{Story()}
], + parameters: { + docs: { + source: { + code: ` + + + + {({ active, title }) => ( + + )} + +
+ + Tab 1 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 2 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 3 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 4 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 5 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 6 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+
+ `, + }, + }, + }, + args: { + children: ( + <> + + + {({ active, title }) => ( + + )} + +
+ + Tab 1 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 2 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 3 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 4 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 5 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ active, title }) => ( + + )} + +
+ + Tab 6 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + ), + }, +}; diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 8e273604b..c4aade3cf 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -9,7 +9,7 @@ import React, { useState, type KeyboardEvent, } from 'react'; -import { allByType } from 'react-children-by-type'; +import { allByType, oneByType } from 'react-children-by-type'; import { L_ARROW_KEYCODE, U_ARROW_KEYCODE, @@ -47,10 +47,18 @@ export interface Props { activeIndex?: number; } +export type TabContextArgs = { + active: boolean; + title: string; +}; + /** * `import {Tabs} from "@chanzuckerberg/eds";` * * List of of links where each link toggles open associated information in a tab panel. + * + * Individual tabs allow for a simple text tab header using the `title` prop on each `` instance. + * For a more custom tabs, you can use an additonal `` sub-component with a render prop exposing `active` and `title`. */ export const Tabs = ({ activeIndex = 0, @@ -208,6 +216,7 @@ export const Tabs = ({ > {tabs.map((tab, i) => { const isActive = activeIndexState === i; + const tabButton = oneByType(tab.props.children, Tab.Button); return (
  • - {tab.props.title} + {typeof tabButton?.props.children === 'function' + ? tabButton.props.children({ + active: isActive, + title: tab.props.title, + }) + : tab.props.title}
  • ); diff --git a/src/components/Tabs/__snapshots__/Tabs.test.tsx.snap b/src/components/Tabs/__snapshots__/Tabs.test.tsx.snap index a62c02162..93f528e0e 100644 --- a/src/components/Tabs/__snapshots__/Tabs.test.tsx.snap +++ b/src/components/Tabs/__snapshots__/Tabs.test.tsx.snap @@ -1,5 +1,301 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` CustomTabs story renders snapshot 1`] = ` +
    +
    + +
    +
    +
    +

    + Tab 1 +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + +

    +
    +
    +
    +
    +
    +`; + exports[` Default story renders snapshot 1`] = `
    , ): React.ReactElement

    ; + + export function withoutTypes

    ( + children: React.ReactNode, + ...types: React.JSXElementConstructor

    [] + ): React.ReactElement

    ; } diff --git a/src/util/utility-types.ts b/src/util/utility-types.ts index 1554410ba..ac678d8ca 100644 --- a/src/util/utility-types.ts +++ b/src/util/utility-types.ts @@ -42,3 +42,11 @@ export type EitherInclusive = EitherExclusive | (T & U); export type ForwardedRefComponent = React.ForwardRefExoticComponent< React.PropsWithoutRef

    & React.RefAttributes >; + +/** + * Utility type used when defining custom render props. Uses a type parameter to define the + * members of the render prop when not a `ReactNode`. + */ +export type RenderProps = { + children: React.ReactNode | ((args: RenderPropArgs) => React.ReactElement); +};