Skip to content

Commit

Permalink
feat: export TabPanel (#6896)
Browse files Browse the repository at this point in the history
  • Loading branch information
bvandercar-vt committed Jul 25, 2024
1 parent d51c90c commit 6491c01
Show file tree
Hide file tree
Showing 9 changed files with 182 additions and 61 deletions.
1 change: 1 addition & 0 deletions packages/core/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export { CheckboxCard, type CheckboxCardProps } from "./control-card/checkboxCar
export { RadioCard, type RadioCardProps } from "./control-card/radioCard";
export { SwitchCard, type SwitchCardProps } from "./control-card/switchCard";
export { Tab, type TabId, type TabProps } from "./tabs/tab";
export { TabPanel, type TabPanelProps } from "./tabs/tabPanel";
// eslint-disable-next-line deprecation/deprecation
export { Tabs, type TabsProps, TabsExpander, Expander } from "./tabs/tabs";
export { CompoundTag, type CompoundTagProps } from "./tag/compoundTag";
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/components/tabs/tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ import type { TagProps } from "../tag/tag";

export type TabId = string | number;

export interface TabIdProps {
/**
* `id` prop of the tab title, and the `aria-labelledby` of the `TabPanel`.
*/
tabTitleId: string;
/**
* `id` prop of the `tabpanel`, and the `aria-controls` of the tab title.
*/
tabPanelId: string;
}

export interface TabProps extends Props, Omit<HTMLDivProps, "id" | "title" | "onClick"> {
/**
* Content of tab title, rendered in a list above the active panel.
Expand All @@ -51,7 +62,7 @@ export interface TabProps extends Props, Omit<HTMLDivProps, "id" | "title" | "on
* If omitted, no panel will be rendered for this tab.
* Can either be an element or a renderer.
*/
panel?: React.JSX.Element | ((props: { tabTitleId: string; tabPanelId: string }) => React.JSX.Element);
panel?: React.JSX.Element | ((props: TabIdProps) => React.JSX.Element);

/**
* Space-delimited string of class names applied to tab panel container.
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/components/tabs/tabPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2024 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import classNames from "classnames";
import * as React from "react";

import { AbstractPureComponent, Classes, Utils } from "../../common";

import { type TabId, type TabProps } from "./tab";
import type { TabsProps } from "./tabs";
import { generateTabIds, type TabTitleProps } from "./tabTitle";

export interface TabPanelProps
extends Pick<TabProps, "className" | "id" | "panel">,
Pick<TabsProps, "renderActiveTabPanelOnly" | "selectedTabId">,
Pick<TabTitleProps, "parentId"> {
/**
* Used for setting visibility. This `TabPanel` will be visibile when `selectedTabId === id`, with proper accessibility attributes set.
*/
selectedTabId: TabId | undefined;
}

/**
* Wraps the passed `panel`.
*/
export class TabPanel extends AbstractPureComponent<TabPanelProps> {
public render() {
const { className, id, parentId, selectedTabId, panel, renderActiveTabPanelOnly } = this.props;

const isSelected = id === selectedTabId;

if (panel === undefined || (renderActiveTabPanelOnly && !isSelected)) {
return undefined;
}

const { tabTitleId, tabPanelId } = generateTabIds(parentId, id);

return (
<div
aria-labelledby={tabTitleId}
aria-hidden={!isSelected}
className={classNames(Classes.TAB_PANEL, className)}
id={tabPanelId}
role="tabpanel"
>
{Utils.isFunction(panel) ? panel({ tabTitleId, tabPanelId }) : panel}
</div>
);
}
}
19 changes: 10 additions & 9 deletions packages/core/src/components/tabs/tabTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { DISPLAYNAME_PREFIX, removeNonHTMLProps } from "../../common/props";
import { Icon } from "../icon/icon";
import { Tag } from "../tag/tag";

import type { TabId, TabProps } from "./tab";
import type { TabId, TabIdProps, TabProps } from "./tab";

export interface TabTitleProps extends TabProps {
/** Optional contents. */
Expand Down Expand Up @@ -55,18 +55,20 @@ export class TabTitle extends AbstractPureComponent<TabTitleProps> {
tagProps,
...htmlProps
} = this.props;

const intent = selected ? Intent.PRIMARY : Intent.NONE;
const { tabPanelId, tabTitleId } = generateTabIds(parentId, id);

return (
<div
{...removeNonHTMLProps(htmlProps)}
aria-controls={generateTabPanelId(parentId, id)}
aria-controls={tabPanelId}
aria-disabled={disabled}
aria-expanded={selected}
aria-selected={selected}
className={classNames(Classes.TAB, className)}
data-tab-id={id}
id={generateTabTitleId(parentId, id)}
id={tabTitleId}
onClick={disabled ? undefined : this.handleClick}
role="tab"
tabIndex={disabled ? undefined : selected ? 0 : -1}
Expand All @@ -91,10 +93,9 @@ export class TabTitle extends AbstractPureComponent<TabTitleProps> {
private handleClick = (e: React.MouseEvent<HTMLElement>) => this.props.onClick(this.props.id, e);
}

export function generateTabPanelId(parentId: TabId, tabId: TabId) {
return `${Classes.TAB_PANEL}_${parentId}_${tabId}`;
}

export function generateTabTitleId(parentId: TabId, tabId: TabId) {
return `${Classes.TAB}-title_${parentId}_${tabId}`;
export function generateTabIds(parentId: TabId, tabId: TabId) {
return {
tabPanelId: `${Classes.TAB_PANEL}_${parentId}_${tabId}`,
tabTitleId: `${Classes.TAB}-title_${parentId}_${tabId}`,
} satisfies TabIdProps;
}
38 changes: 35 additions & 3 deletions packages/core/src/components/tabs/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ you can even insert things _between_ `<Tab>` elements.
```tsx
import { Tab, Tabs } from "@blueprintjs/core";

<Tabs id="TabsExample" onChange={this.handleTabChange} selectedTabId="rx">
<Tabs id="TabsExample">
<Tab id="ng" title="Angular" panel={<AngularPanel />} />
<Tab id="mb" title="Ember" panel={<EmberPanel />} panelClassName="ember-panel" />
<Tab id="rx" title="React" panel={<ReactPanel />} />
Expand Down Expand Up @@ -47,11 +47,43 @@ The __Tab__ component is a minimal wrapper with no functionality of its own&mdas
parent __Tabs__ component. Tab title text can be set either via `title` prop or via React children
(for more complex content).

The associated tab `panel` will be visible when the _Tab__ is active. Omitting the `panel` prop is supported; this can
be useful when you want the associated panel to appear elsewhere in the DOM (by rendering it yourself as needed).
The associated tab `panel` will be visible when the _Tab_ is active. Omitting the `panel` prop is supported; this can
be useful when you want the associated panel to appear elsewhere in the DOM (by rendering it yourself as needed&mdash;see _TabPanel_).

@interface TabProps

@### TabPanel

__TabPanel__ wraps a passed `panel` in proper aria attributes, `id`, and `role`, for proper accessibility. A __TabPanel__ gets automatically rendered by a _Tab_ when `panel` is supplied and the _Tab_ is active, but __TabPanel__ is also exported for cases where you want to render the panel yourself elsewhere in the DOM, while using _Tabs_ in controlled mode:

```tsx
import * as React from "react";
import { Tab, Tabs, TabPanel, type TabId } from "@blueprintjs/core";

function TabsControlledExample() {
const TABS_PARENT_ID = React.useId();
const [selectedTabId, setSelectedTabId] = React.useState<TabId>("Home");

return (
<>
<Tabs id={TABS_PARENT_ID} onChange={setSelectedTabId} selectedTabId={selectedTabId}>
<Tab id="Home" title="Home" />
<Tab id="Files" title="Files" />
</Tabs>
<TabPanel
id={selectedTabId}
selectedTabId={selectedTabId}
parentId={TABS_PARENT_ID}
panel={<p>The current panel id is: "{selectedTabId}"</p>}
/>
</>
);
}

```

@interface TabPanelProps

@## CSS API

<div class="@ns-callout @ns-intent-warning @ns-icon-warning-sign @ns-callout-has-body-content">
Expand Down
21 changes: 8 additions & 13 deletions packages/core/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import * as React from "react";
import { AbstractPureComponent, Classes, DISPLAYNAME_PREFIX, type Props, Utils } from "../../common";

import { Tab, type TabId, type TabProps } from "./tab";
import { generateTabPanelId, generateTabTitleId, TabTitle } from "./tabTitle";
import { TabPanel } from "./tabPanel";
import { TabTitle } from "./tabTitle";

/**
* Component that may be inserted between any two children of `<Tabs>` to right-align all subsequent children.
Expand Down Expand Up @@ -330,20 +331,14 @@ export class Tabs extends AbstractPureComponent<TabsProps, TabsState> {
return undefined;
}

const tabTitleId = generateTabTitleId(this.props.id, id);
const tabPanelId = generateTabPanelId(this.props.id, id);

return (
<div
aria-labelledby={tabTitleId}
aria-hidden={id !== this.state.selectedTabId}
className={classNames(Classes.TAB_PANEL, className, panelClassName)}
id={tabPanelId}
<TabPanel
{...tab.props}
key={id}
role="tabpanel"
>
{Utils.isFunction(panel) ? panel({ tabTitleId, tabPanelId }) : panel}
</div>
className={classNames(className, panelClassName)}
parentId={this.props.id}
selectedTabId={this.state.selectedTabId}
/>
);
};

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/isotest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ describe("@blueprintjs/core isomorphic rendering", () => {
"ContextMenuTargetLegacy",
"Expander",
"HotkeysTarget",
"TabPanel",
],
},
);
Expand Down
9 changes: 5 additions & 4 deletions packages/core/test/tabs/tabsTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { spy } from "sinon";
import { Classes } from "../../src/common";
import { Tab } from "../../src/components/tabs/tab";
import { Tabs, type TabsProps, type TabsState } from "../../src/components/tabs/tabs";
import { generateTabPanelId, generateTabTitleId } from "../../src/components/tabs/tabTitle";
import { generateTabIds } from "../../src/components/tabs/tabTitle";

describe("<Tabs>", () => {
const ID = "tabsTests";
Expand Down Expand Up @@ -134,17 +134,18 @@ describe("<Tabs>", () => {
const NUM_TABS = 3;
assert.lengthOf(wrapper.find(TAB_SELECTOR), NUM_TABS);
assert.lengthOf(wrapper.find(TAB_PANEL_SELECTOR), NUM_TABS);
assert.lengthOf(wrapper.find(`.${panelClassName}`), 1);
assert.lengthOf(wrapper.find(`.${panelClassName}`).hostNodes(), 1);
});

it("passes correct tabTitleId and tabPanelId to panel renderer", () => {
const expectedIds = generateTabIds(ID, "first");
mount(
<Tabs id={ID}>
<Tab
id="first"
panel={({ tabTitleId, tabPanelId }) => {
assert.equal(tabTitleId, generateTabTitleId(ID, "first"));
assert.equal(tabPanelId, generateTabPanelId(ID, "first"));
assert.equal(tabTitleId, expectedIds.tabTitleId);
assert.equal(tabPanelId, expectedIds.tabPanelId);
return <Panel title="a" />;
}}
/>
Expand Down
78 changes: 47 additions & 31 deletions packages/docs-app/src/examples/core-examples/tabsExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
Switch,
Tab,
type TabId,
TabPanel,
Tabs,
TabsExpander,
} from "@blueprintjs/core";
Expand Down Expand Up @@ -105,39 +106,11 @@ export class TabsExample extends React.PureComponent<ExampleProps, TabsExampleSt
</>
);

const NAVBAR_PARENT_ID = "navbar";

return (
<Example className="docs-tabs-example" options={options} {...this.props}>
<H4>Tabs without panels, controlled mode</H4>
<Switch checked={this.state.fill} label="Fill height" onChange={this.toggleFill} />
<Navbar>
<Navbar.Group>
<Navbar.Heading>
Page: <strong>{this.state.navbarTabId}</strong>
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<Tabs
animate={this.state.animate}
fill={this.state.fill}
id="navbar"
large={this.state.large}
onChange={this.handleNavbarTabChange}
selectedTabId={this.state.navbarTabId}
>
<Tab id="Home" title="Home" icon={this.state.showIcon ? "home" : undefined} />
<Tab id="Files" title="Files" icon={this.state.showIcon ? "folder-open" : undefined} />
<Tab
id="Builds"
title="Builds"
icon={this.state.showIcon ? "build" : undefined}
tagContent={this.state.showTags ? 4 : undefined}
tagProps={{ round: this.state.useRoundTags }}
/>
</Tabs>
</Navbar.Group>
</Navbar>
<Divider style={{ margin: "20px 0", width: "100%" }} />
<H4>Tabs with panels, uncontrolled mode</H4>
<H4>Tabs with passed panels, uncontrolled mode</H4>
<Switch checked={this.state.vertical} label="Use vertical tabs" onChange={this.toggleVertical} />
<Tabs
animate={this.state.animate}
Expand All @@ -160,6 +133,49 @@ export class TabsExample extends React.PureComponent<ExampleProps, TabsExampleSt
<TabsExpander />
<InputGroup fill={true} type="text" placeholder="Search..." />
</Tabs>
<Divider style={{ margin: "20px 0", width: "100%" }} />
<H4>Tabs with separately rendered panels, controlled mode</H4>
<Switch checked={this.state.fill} label="Fill height" onChange={this.toggleFill} />
<div className={Classes.SECTION}>
<Navbar>
<Navbar.Group>
<Navbar.Heading>
Page: <strong>{this.state.navbarTabId}</strong>
</Navbar.Heading>
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<Tabs
animate={this.state.animate}
fill={this.state.fill}
id={NAVBAR_PARENT_ID}
large={this.state.large}
onChange={this.handleNavbarTabChange}
selectedTabId={this.state.navbarTabId}
>
<Tab id="Home" title="Home" icon={this.state.showIcon ? "home" : undefined} />
<Tab id="Files" title="Files" icon={this.state.showIcon ? "folder-open" : undefined} />
<Tab
id="Builds"
title="Builds"
icon={this.state.showIcon ? "build" : undefined}
tagContent={this.state.showTags ? 4 : undefined}
tagProps={{ round: this.state.useRoundTags }}
/>
</Tabs>
</Navbar.Group>
</Navbar>
<TabPanel
id={this.state.navbarTabId}
selectedTabId={this.state.navbarTabId}
parentId={NAVBAR_PARENT_ID}
panel={
<>
<H4>Example panel: {this.state.navbarTabId}</H4>
<p>The current panel is: "{this.state.navbarTabId}"</p>
</>
}
/>
</div>
</Example>
);
}
Expand Down

1 comment on commit 6491c01

@svc-palantir-github
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feat: export TabPanel (#6896)

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Please sign in to comment.