diff --git a/packages/core/src/common/errors.ts b/packages/core/src/common/errors.ts index 0105d0804f..cb1b082a79 100644 --- a/packages/core/src/common/errors.ts +++ b/packages/core/src/common/errors.ts @@ -71,13 +71,6 @@ export const SLIDER_ZERO_STEP = ns + ` stepSize must be greater than ze export const SLIDER_ZERO_LABEL_STEP = ns + ` labelStepSize must be greater than zero.`; export const RANGESLIDER_NULL_VALUE = ns + ` value prop must be an array of two non-null numbers.`; -export const TABS_FIRST_CHILD = ns + ` First child of component must be a `; -export const TABS_MISMATCH = ns + ` Number of components must equal number of components`; -export const TABS_WARN_DEPRECATED = - deprec + - ` is deprecated since v1.11.0; consider upgrading to .` + - " https://blueprintjs.com/#components.tabs.js"; - export const TOASTER_WARN_INLINE = ns + ` Toaster.create() ignores inline prop as it always creates a new element.`; export const TOASTER_WARN_LEFT_RIGHT = ns + ` Toaster does not support LEFT or RIGHT positions.`; diff --git a/packages/core/src/components/components.md b/packages/core/src/components/components.md index 6d113eb619..85a0d6bc63 100644 --- a/packages/core/src/components/components.md +++ b/packages/core/src/components/components.md @@ -27,7 +27,6 @@ @page sliders @page table @page tabs -@page tabs2 @page tag @page tag-input @page text diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 59736f0aba..f0601adf36 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -59,10 +59,6 @@ export * from "./spinner/spinner"; export * from "./spinner/svgSpinner"; export * from "./tabs/tab"; export * from "./tabs/tabs"; -export * from "./tabs/tabList"; -export * from "./tabs/tabPanel"; -export * from "./tabs2/tab2"; -export * from "./tabs2/tabs2"; export * from "./tag/tag"; export * from "./tag-input/tagInput"; export * from "./toast/toast"; diff --git a/packages/core/src/components/tabs/tab.tsx b/packages/core/src/components/tabs/tab.tsx index b2a4adf14d..6bdfc1aa4f 100644 --- a/packages/core/src/components/tabs/tab.tsx +++ b/packages/core/src/components/tabs/tab.tsx @@ -10,54 +10,50 @@ import * as React from "react"; import * as Classes from "../../common/classes"; import { IProps } from "../../common/props"; +export type TabId = string | number; + export interface ITabProps extends IProps { /** - * Element ID. - * @internal + * Whether the tab is disabled. + * @default false */ - id?: string; + disabled?: boolean; /** - * Whether the tab is disabled. - * @default false + * Unique identifier used to control which tab is selected + * and to generate ARIA attributes for accessibility. */ - isDisabled?: boolean; + id: TabId; /** - * Whether the tab is currently selected. - * @internal + * Panel content, rendered by the parent `Tabs` when this tab is active. + * If omitted, no panel will be rendered for this tab. */ - isSelected?: boolean; + panel?: JSX.Element; /** - * The ID of the tab panel which this tab corresponds to. - * @internal + * Content of tab title element, rendered in a list above the active panel. + * Can also be set via React `children`. */ - panelId?: string; + title?: string | JSX.Element; } export class Tab extends React.PureComponent { public static defaultProps: ITabProps = { - isDisabled: false, - isSelected: false, + disabled: false, + id: undefined, }; public static displayName = "Blueprint.Tab"; + // this component is never rendered directly; see Tabs#renderTabPanel() + /* istanbul ignore next */ public render() { + const { className, panel } = this.props; return ( - +
+ {panel} +
); } } diff --git a/packages/core/src/components/tabs/tabList.tsx b/packages/core/src/components/tabs/tabList.tsx deleted file mode 100644 index 8b0315f70b..0000000000 --- a/packages/core/src/components/tabs/tabList.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2015 Palantir Technologies, Inc. All rights reserved. - * - * Licensed under the terms of the LICENSE file distributed with this project. - */ - -import * as classNames from "classnames"; -import * as React from "react"; - -import { AbstractPureComponent } from "../../common/abstractPureComponent"; -import * as Classes from "../../common/classes"; -import { IProps } from "../../common/props"; - -export interface ITabListProps extends IProps { - /** - * The list of CSS rules to use on the indicator wrapper. - * @internal - */ - indicatorWrapperStyle?: React.CSSProperties; -} - -export interface ITabListState { - /** - * Whether the animation should be run when transform changes. - */ - shouldAnimate?: boolean; -} - -export class TabList extends AbstractPureComponent { - public static displayName = "Blueprint.TabList"; - - public state: ITabListState = { - shouldAnimate: false, - }; - - public render() { - return ( -
    -
    -
    -
    - {this.props.children} -
- ); - } - - public componentDidUpdate(prevProps: ITabListProps) { - if (prevProps.indicatorWrapperStyle == null) { - this.setTimeout(() => this.setState({ shouldAnimate: true })); - } - } -} - -export const TabListFactory = React.createFactory(TabList); diff --git a/packages/core/src/components/tabs/tabPanel.tsx b/packages/core/src/components/tabs/tabPanel.tsx deleted file mode 100644 index b4f216a918..0000000000 --- a/packages/core/src/components/tabs/tabPanel.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2015 Palantir Technologies, Inc. All rights reserved. - * - * Licensed under the terms of the LICENSE file distributed with this project. - */ - -import * as classNames from "classnames"; -import * as React from "react"; - -import * as Classes from "../../common/classes"; -import { IProps } from "../../common/props"; - -// properties with underscores should not be set by users (we set them in the component). -export interface ITabPanelProps extends IProps { - /** - * Element ID. - */ - _id?: string; - - /** - * The ID of the tab this panel corresponds to. - */ - _tabId?: string; -} - -export class TabPanel extends React.PureComponent { - public static displayName = "Blueprint.TabPanel"; - - public render() { - return ( -
- {this.props.children} -
- ); - } -} - -export const TabPanelFactory = React.createFactory(TabPanel); diff --git a/packages/core/src/components/tabs2/tabTitle.tsx b/packages/core/src/components/tabs/tabTitle.tsx similarity index 94% rename from packages/core/src/components/tabs2/tabTitle.tsx rename to packages/core/src/components/tabs/tabTitle.tsx index 514557f8d4..6cf9c7fd05 100644 --- a/packages/core/src/components/tabs2/tabTitle.tsx +++ b/packages/core/src/components/tabs/tabTitle.tsx @@ -8,9 +8,9 @@ import * as classNames from "classnames"; import * as React from "react"; import * as Classes from "../../common/classes"; -import { ITab2Props, TabId } from "./tab2"; +import { ITabProps, TabId } from "./tab"; -export interface ITabTitleProps extends ITab2Props { +export interface ITabTitleProps extends ITabProps { /** Handler invoked when this tab is clicked. */ onClick: (id: TabId, event: React.MouseEvent) => void; diff --git a/packages/core/src/components/tabs/tabs.md b/packages/core/src/components/tabs/tabs.md index 58ba78ce6e..3fc559403b 100644 --- a/packages/core/src/components/tabs/tabs.md +++ b/packages/core/src/components/tabs/tabs.md @@ -1,14 +1,8 @@ @# Tabs -
-
The `Tabs` JavaScript API is deprecated since v1.11.0
- The following `Tabs` React components been deprecated in v1.11.0 favor of the [simpler and more flexible - `Tabs2` API](#core/components/tabs2). `Tabs2` will replace `Tabs` in version 2.0. The CSS API has not been changed. -
- @## CSS API -In addition to the [JavaScript API](#core/components/tabs2.javascript-api), Blueprint also offers tab styles with the +In addition to the [JavaScript API](#core/components/tabs.javascript-api), Blueprint also offers tab styles with the class `pt-tabs`. You should add the proper accessibility attributes (`role`, `aria-selected`, and `aria-hidden`) if you choose to implement tabs with CSS. @@ -18,106 +12,55 @@ JavaScript component does this by default). @css pt-tabs -@## Deprecated JavaScript API - -
- These components are deprecated since v1.11.0. Please use the [`Tabs2` API](#core/components/tabs2) instead. -
+@## JavaScript API -The `Tabs`, `TabList`, `Tab`, and `TabPanel` components are available in the __@blueprintjs/core__ +The `Tabs` and `Tab` components are available in the __@blueprintjs/core__ package. Make sure to review the [general usage docs for JS components](#blueprint.usage). -Four components are necessary to render tabs: `Tabs`, `TabList`, `Tab`, and `TabPanel`. - -For performance reasons, only the currently active `TabPanel` is rendered into the DOM. When the -user switches tabs, data stored in the DOM is lost. This is not an issue in React applications -because of how the library manages the virtual DOM for you. +Tab selection is managed by `id`, much like the HTML `
``` -Every component accepts a `className` prop that can be used to set additional classes on the -component's root element. You can get larger tabs by using the `pt-large` class on `TabList`. - -You can use the `Tabs` API in controlled or uncontrolled mode. The props you supply will differ -between these approaches. - @reactExample TabsExample -@### Tabs props +@### Tabs -
- This component is deprecated since v1.11.0. Please use the [`Tabs2` API](#core/components/tabs2) instead. -
+`Tabs` is the top-level component responsible for rendering the tab list and coordinating selection. +It can be used in controlled mode by providing `selectedTabId` and `onChange` props, or in +uncontrolled mode by optionally providing `defaultSelectedTabId` and `onChange`. -@interface ITabsProps +Children of the `Tabs` are rendered in order in the tab list, which is a flex container. +`Tab` children are managed by the component; clicking one will change selection. Arbitrary other +children are simply rendered in order; interactions are your responsibility. -@### Tab props +Insert a `` between any two children to right-align all subsequent children (or bottom-align when `vertical`). -
- This component is deprecated since v1.11.0. Please use the [`Tabs2` API](#core/components/tabs2) instead. -
+@interface ITabsProps -@interface ITabProps +@### Tab -@### Usage with React Router +`Tab` is a minimal wrapper with no functionality of its own—it is managed entirely by its +parent `Tabs` wrapper. Tab title text can be set either via `title` prop or via React children +(for more complex content). -Often, you'll want to link tab navigation to overall app navigation, including updating the URL. -[react-router](https://github.com/reactjs/react-router) is a commonly-used library for React -applications. Here's how you might configure tabs to work with it: +The associated tab `panel` will be visible when the `Tab` is active. Omitting `panel` is perfectly +safe and allows you to control exactly where the panel appears in the DOM (by rendering it yourself +as needed). -```tsx -import { render } from "react-dom"; -import { Router, Route } from "react-router"; -import { Tabs, TabList, Tab, TabPanel } from "@blueprintjs/core"; - -const App = () => { ... }; - -// keys are necessary in JSX.Element lists to keep React happy -const contents = [ - - Home - Projects - , - - home things - , - - projects things - , -]; - -// using SFCs from TS 1.8, but easy to do without them -export const Home = () => {contents}; -export const Projects = () => {contents}; - -render( - - - - , - document.querySelector("#app") -); -``` +@interface ITabProps diff --git a/packages/core/src/components/tabs/tabs.tsx b/packages/core/src/components/tabs/tabs.tsx index 5f6760cc82..6e583500df 100644 --- a/packages/core/src/components/tabs/tabs.tsx +++ b/packages/core/src/components/tabs/tabs.tsx @@ -6,381 +6,317 @@ import * as classNames from "classnames"; import * as React from "react"; -import { findDOMNode } from "react-dom"; import { AbstractPureComponent } from "../../common/abstractPureComponent"; import * as Classes from "../../common/classes"; -import * as Errors from "../../common/errors"; import * as Keys from "../../common/keys"; import { IProps } from "../../common/props"; import * as Utils from "../../common/utils"; -import { ITabProps, Tab } from "./tab"; -import { ITabListProps, TabList } from "./tabList"; -import { TabPanel } from "./tabPanel"; +import { ITabProps, Tab, TabId } from "./tab"; +import { generateTabPanelId, generateTabTitleId, TabTitle } from "./tabTitle"; + +export const Expander: React.SFC<{}> = () =>
; + +type TabElement = React.ReactElement; + +const TAB_SELECTOR = `.${Classes.TAB}`; export interface ITabsProps extends IProps { /** - * The index of the initially selected tab when this component renders. - * This prop has no effect if `selectedTabIndex` is also provided. - * @default 0 + * Whether the selected tab indicator should animate its movement. + * @default true */ - initialSelectedTabIndex?: number; + animate?: boolean; /** - * The index of the currently selected tab. - * Use this prop if you want to explicitly control the currently displayed panel - * yourself with the `onChange` event handler. - * If this prop is left undefined, the component changes tab panels automatically - * when tabs are clicked. + * Initial selected tab `id`, for uncontrolled usage. + * Note that this prop refers only to `` children; other types of elements are ignored. + * @default first tab */ - selectedTabIndex?: number; + defaultSelectedTabId?: TabId; /** - * A callback function that is invoked when tabs in the tab list are clicked. + * Unique identifier for this `Tabs` container. This will be combined with the `id` of each + * `Tab` child to generate ARIA accessibility attributes. IDs are required and should be + * unique on the page to support server-side rendering. */ - onChange?(selectedTabIndex: number, prevSelectedTabIndex: number): void; -} + id: TabId; -export interface ITabsState { /** - * The list of CSS rules to use on the indicator wrapper of the tab list. + * If set to `true`, the tabs will display with larger styling. + * This is equivalent to setting `pt-large` on the `.pt-tab-list` element. + * This will apply large styles only to the tabs at this level, not to nested tabs. + * @default false */ - indicatorWrapperStyle?: React.CSSProperties; + large?: boolean; + + /** + * Whether inactive tab panels should be removed from the DOM and unmounted in React. + * This can be a performance enhancement when rendering many complex panels, but requires + * careful support for unmounting and remounting. + * @default false + */ + renderActiveTabPanelOnly?: boolean; /** - * The index of the currently selected tab. - * If a prop with the same name is set, this bit of state simply aliases the prop. + * Selected tab `id`, for controlled usage. + * Providing this prop will put the component in controlled mode. + * Unknown ids will result in empty selection (no errors). */ - selectedTabIndex?: number; + selectedTabId?: TabId; + + /** + * Whether to show tabs stacked vertically on the left side. + * @default false + */ + vertical?: boolean; + + /** + * A callback function that is invoked when a tab in the tab list is clicked. + */ + onChange?(newTabId: TabId, prevTabId: TabId, event: React.MouseEvent): void; } -const TAB_CSS_SELECTOR = "li[role=tab]"; +export interface ITabsState { + indicatorWrapperStyle?: React.CSSProperties; + selectedTabId?: TabId; +} export class Tabs extends AbstractPureComponent { - public static defaultProps: ITabsProps = { - initialSelectedTabIndex: 0, - }; + /** Insert a `Tabs.Expander` between any two children to right-align all subsequent children. */ + public static Expander = Expander; - public static displayName = "Blueprint.Tabs"; + public static Tab = Tab; - // state is initialized in the constructor but getStateFromProps needs state defined - public state: ITabsState = {}; + public static defaultProps: Partial = { + animate: true, + large: false, + renderActiveTabPanelOnly: false, + vertical: false, + }; - private panelIds: string[] = []; - private tabIds: string[] = []; + public static displayName = "Blueprint.Tabs"; - constructor(props?: ITabsProps, context?: any) { - super(props, context); - this.state = this.getStateFromProps(this.props); + private tablistElement: HTMLDivElement; + private refHandlers = { + tablist: (tabElement: HTMLDivElement) => (this.tablistElement = tabElement), + }; - if (!Utils.isNodeEnv("production")) { - console.warn(Errors.TABS_WARN_DEPRECATED); - } + constructor(props?: ITabsProps) { + super(props); + const selectedTabId = this.getInitialSelectedTabId(); + this.state = { selectedTabId }; } public render() { - return ( -
- {this.getChildren()} + const { indicatorWrapperStyle, selectedTabId } = this.state; + + const tabTitles = React.Children.map( + this.props.children, + child => (isTab(child) ? this.renderTabTitle(child) : child), + ); + + const tabPanels = this.getTabChildren() + .filter(this.props.renderActiveTabPanelOnly ? tab => tab.props.id === selectedTabId : () => true) + .map(this.renderTabPanel); + + const tabIndicator = this.props.animate ? ( +
+
+ ) : ( + undefined ); - } - public componentWillReceiveProps(newProps: ITabsProps) { - const newState = this.getStateFromProps(newProps); - this.setState(newState); + const classes = classNames(Classes.TABS, { [Classes.VERTICAL]: this.props.vertical }, this.props.className); + const tabListClasses = classNames(Classes.TAB_LIST, { + [Classes.LARGE]: this.props.large, + }); + + return ( +
+
+ {tabIndicator} + {tabTitles} +
+ {tabPanels} +
+ ); } public componentDidMount() { - const selectedTab = findDOMNode(this.refs[`tabs-${this.state.selectedTabIndex}`]) as HTMLElement; - this.setTimeout(() => this.moveIndicator(selectedTab)); + this.moveSelectionIndicator(); } - public componentDidUpdate(_: ITabsProps, prevState: ITabsState) { - const newIndex = this.state.selectedTabIndex; - if (newIndex !== prevState.selectedTabIndex) { - const tabElement = findDOMNode(this.refs[`tabs-${newIndex}`]) as HTMLElement; - // need to measure on the next frame in case the Tab children simultaneously change - this.setTimeout(() => this.moveIndicator(tabElement)); + public componentWillReceiveProps({ selectedTabId }: ITabsProps) { + if (selectedTabId !== undefined) { + // keep state in sync with controlled prop, so state is canonical source of truth + this.setState({ selectedTabId }); } } - protected validateProps(props: ITabsProps & { children?: React.ReactNode }) { - if (React.Children.count(props.children) > 0) { - const child = React.Children.toArray(props.children)[0] as React.ReactElement; - if (child != null && child.type !== TabList) { - throw new Error(Errors.TABS_FIRST_CHILD); - } - - if (this.getTabsCount() !== this.getPanelsCount()) { - throw new Error(Errors.TABS_MISMATCH); + public componentDidUpdate(prevProps: ITabsProps, prevState: ITabsState) { + if (this.state.selectedTabId !== prevState.selectedTabId) { + this.moveSelectionIndicator(); + } else if (prevState.selectedTabId != null) { + // comparing React nodes is difficult to do with simple logic, so + // shallowly compare just their props as a workaround. + const didChildrenChange = !Utils.arraysEqual( + this.getTabChildrenProps(prevProps), + this.getTabChildrenProps(), + Utils.shallowCompareKeys, + ); + if (didChildrenChange) { + this.moveSelectionIndicator(); } } } - private handleClick = (e: React.SyntheticEvent) => { - this.handleTabSelectingEvent(e); - }; - - private handleKeyPress = (e: React.KeyboardEvent) => { - const insideTab = (e.target as HTMLElement).closest(`.${Classes.TAB}`) != null; - if (insideTab && (e.which === Keys.SPACE || e.which === Keys.ENTER)) { - e.preventDefault(); - this.handleTabSelectingEvent(e); - } - }; - - private handleKeyDown = (e: React.KeyboardEvent) => { - // don't want to handle keyDown events inside a tab panel - const insideTabList = (e.target as HTMLElement).closest(`.${Classes.TAB_LIST}`) != null; - if (!insideTabList) { - return; - } - - const focusedTabIndex = this.getFocusedTabIndex(); - if (focusedTabIndex === -1) { - return; - } - - if (e.which === Keys.ARROW_LEFT) { - e.preventDefault(); - - // find previous tab that isn't disabled - let newTabIndex = focusedTabIndex - 1; - let tabIsDisabled = this.isTabDisabled(newTabIndex); - - while (tabIsDisabled && newTabIndex !== -1) { - newTabIndex--; - tabIsDisabled = this.isTabDisabled(newTabIndex); - } - - if (newTabIndex !== -1) { - this.focusTab(newTabIndex); - } - } else if (e.which === Keys.ARROW_RIGHT) { - e.preventDefault(); - - // find next tab that isn't disabled - const tabsCount = this.getTabsCount(); - - let newTabIndex = focusedTabIndex + 1; - let tabIsDisabled = this.isTabDisabled(newTabIndex); - - while (tabIsDisabled && newTabIndex !== tabsCount) { - newTabIndex++; - tabIsDisabled = this.isTabDisabled(newTabIndex); - } - - if (newTabIndex !== tabsCount) { - this.focusTab(newTabIndex); - } - } - }; - - private handleTabSelectingEvent = (e: React.SyntheticEvent) => { - const tabElement = (e.target as HTMLElement).closest(TAB_CSS_SELECTOR) as HTMLElement; - - // select only if Tab is one of us and is enabled - if ( - tabElement != null && - this.tabIds.indexOf(tabElement.id) >= 0 && - tabElement.getAttribute("aria-disabled") !== "true" - ) { - const index = tabElement.parentElement.queryAll(TAB_CSS_SELECTOR).indexOf(tabElement); - - this.setSelectedTabIndex(index); + private getInitialSelectedTabId() { + // NOTE: providing an unknown ID will hide the selection + const { defaultSelectedTabId, selectedTabId } = this.props; + if (selectedTabId !== undefined) { + return selectedTabId; + } else if (defaultSelectedTabId !== undefined) { + return defaultSelectedTabId; + } else { + // select first tab in absence of user input + const tabs = this.getTabChildren(); + return tabs.length === 0 ? undefined : tabs[0].props.id; } - }; - - /** - * Calculate the new height, width, and position of the tab indicator. - * Store the CSS values so the transition animation can start. - */ - private moveIndicator({ clientHeight, clientWidth, offsetLeft, offsetTop }: HTMLElement) { - const indicatorWrapperStyle = { - height: clientHeight, - transform: `translateX(${Math.floor(offsetLeft)}px) translateY(${Math.floor(offsetTop)}px)`, - width: clientWidth, - }; - this.setState({ indicatorWrapperStyle }); } - /** - * Most of the component logic lives here. We clone the children provided by the user to set up refs, - * accessibility attributes, and selection props correctly. - */ - private getChildren() { - for (let unassignedTabs = this.getTabsCount() - this.tabIds.length; unassignedTabs > 0; unassignedTabs--) { - this.tabIds.push(generateTabId()); - this.panelIds.push(generatePanelId()); + private getKeyCodeDirection(e: React.KeyboardEvent) { + if (isEventKeyCode(e, Keys.ARROW_LEFT, Keys.ARROW_UP)) { + return -1; + } else if (isEventKeyCode(e, Keys.ARROW_RIGHT, Keys.ARROW_DOWN)) { + return 1; } - - let childIndex = 0; - return React.Children.map(this.props.children, (child: React.ReactElement) => { - let result: React.ReactElement; - - // can be null if conditionally rendering TabList / TabPanel - if (child == null) { - return null; - } - - if (childIndex === 0) { - // clone TabList / Tab elements - result = this.cloneTabList(child); - } else { - const tabPanelIndex = childIndex - 1; - const shouldRenderTabPanel = this.state.selectedTabIndex === tabPanelIndex; - result = shouldRenderTabPanel ? this.cloneTabPanel(child, tabPanelIndex) : null; - } - - childIndex++; - return result; - }); + return undefined; } - private cloneTabList(child: React.ReactElement) { - let tabIndex = 0; - const tabs = React.Children.map(child.props.children, (tab: React.ReactElement) => { - // can be null if conditionally rendering Tab - if (tab == null) { - return null; - } - - const clonedTab = React.cloneElement(tab, { - id: this.tabIds[tabIndex], - isSelected: this.state.selectedTabIndex === tabIndex, - panelId: this.panelIds[tabIndex], - ref: `tabs-${tabIndex}`, - }); - tabIndex++; - return clonedTab; - }); - // tslint:disable-next-line no-object-literal-type-assertion - return React.cloneElement(child, { - children: tabs, - indicatorWrapperStyle: this.state.indicatorWrapperStyle, - ref: "tablist", - } as ITabListProps); + private getTabChildrenProps(props: ITabsProps & { children?: React.ReactNode } = this.props) { + return this.getTabChildren(props).map(child => child.props); } - private cloneTabPanel(child: React.ReactElement, tabIndex: number) { - return React.cloneElement(child, { - id: this.panelIds[tabIndex], - isSelected: this.state.selectedTabIndex === tabIndex, - ref: `panels-${tabIndex}`, - tabId: this.tabIds[tabIndex], - }); + /** Filters children to only ``s */ + private getTabChildren(props: ITabsProps & { children?: React.ReactNode } = this.props) { + return React.Children.toArray(props.children).filter(isTab) as TabElement[]; } - private focusTab(index: number) { - const ref = `tabs-${index}`; - const tab = findDOMNode(this.refs[ref]) as HTMLElement; - tab.focus(); - } - - private getFocusedTabIndex() { - const focusedElement = document.activeElement; - if (focusedElement != null && focusedElement.classList.contains(Classes.TAB)) { - const tabId = focusedElement.id; - return this.tabIds.indexOf(tabId); + /** Queries root HTML element for all `.pt-tab`s with optional filter selector */ + private getTabElements(subselector = "") { + if (this.tablistElement == null) { + return [] as Elements; } - return -1; + return this.tablistElement.queryAll(TAB_SELECTOR + subselector); } - private getTabs() { - if (this.props.children == null) { - return []; - } - const tabs: Array> = []; - if (React.Children.count(this.props.children) > 0) { - const firstChild = React.Children.toArray(this.props.children)[0] as React.ReactElement; - if (firstChild != null) { - React.Children.forEach(firstChild.props.children, (tabListChild: React.ReactElement) => { - if (tabListChild.type === Tab) { - tabs.push(tabListChild); - } - }); - } + private handleKeyDown = (e: React.KeyboardEvent) => { + const focusedElement = document.activeElement.closest(TAB_SELECTOR); + // rest of this is potentially expensive and futile, so bail if no tab is focused + if (focusedElement == null) { + return; } - return tabs; - } - private getTabsCount() { - return this.getTabs().length; - } + // must rely on DOM state because we have no way of mapping `focusedElement` to a JSX.Element + const enabledTabElements = this.getTabElements().filter(el => el.getAttribute("aria-disabled") === "false"); + const focusedIndex = enabledTabElements.indexOf(focusedElement); + const direction = this.getKeyCodeDirection(e); - private getPanelsCount() { - if (this.props.children == null) { - return 0; + if (focusedIndex >= 0 && direction !== undefined) { + e.preventDefault(); + const { length } = enabledTabElements; + // auto-wrapping at 0 and `length` + const nextFocusedIndex = (focusedIndex + direction + length) % length; + (enabledTabElements[nextFocusedIndex] as HTMLElement).focus(); } + }; - let panelCount = 0; - React.Children.forEach(this.props.children, (child: React.ReactElement) => { - if (child.type === TabPanel) { - panelCount++; - } - }); - - return panelCount; - } - - private getStateFromProps(props: ITabsProps): ITabsState { - const { selectedTabIndex, initialSelectedTabIndex } = props; - - if (this.isValidTabIndex(selectedTabIndex)) { - return { selectedTabIndex }; - } else if (this.isValidTabIndex(initialSelectedTabIndex) && this.state.selectedTabIndex == null) { - return { selectedTabIndex: initialSelectedTabIndex }; - } else { - return this.state; + private handleKeyPress = (e: React.KeyboardEvent) => { + const targetTabElement = (e.target as HTMLElement).closest(TAB_SELECTOR) as HTMLElement; + if (targetTabElement != null && isEventKeyCode(e, Keys.SPACE, Keys.ENTER)) { + e.preventDefault(); + targetTabElement.click(); } - } - - private isTabDisabled(index: number) { - const tab = this.getTabs()[index]; - return tab != null && tab.props.isDisabled; - } + }; - private isValidTabIndex(index: number) { - return index != null && index >= 0 && index < this.getTabsCount(); - } + private handleTabClick = (newTabId: TabId, event: React.MouseEvent) => { + Utils.safeInvoke(this.props.onChange, newTabId, this.state.selectedTabId, event); + if (this.props.selectedTabId === undefined) { + this.setState({ selectedTabId: newTabId }); + } + }; /** - * Updates the component's state if uncontrolled and calls onChange. + * Calculate the new height, width, and position of the tab indicator. + * Store the CSS values so the transition animation can start. */ - private setSelectedTabIndex(index: number) { - if (index === this.state.selectedTabIndex || !this.isValidTabIndex(index)) { + private moveSelectionIndicator() { + if (this.tablistElement === undefined || !this.props.animate) { return; } - const prevSelectedIndex = this.state.selectedTabIndex; - - if (this.props.selectedTabIndex == null) { - this.setState({ - selectedTabIndex: index, - }); + const tabIdSelector = `${TAB_SELECTOR}[data-tab-id="${this.state.selectedTabId}"]`; + const selectedTabElement = this.tablistElement.query(tabIdSelector) as HTMLElement; + + let indicatorWrapperStyle: React.CSSProperties = { display: "none" }; + if (selectedTabElement != null) { + const { clientHeight, clientWidth, offsetLeft, offsetTop } = selectedTabElement; + indicatorWrapperStyle = { + height: clientHeight, + transform: `translateX(${Math.floor(offsetLeft)}px) translateY(${Math.floor(offsetTop)}px)`, + width: clientWidth, + }; } + this.setState({ indicatorWrapperStyle }); + } - if (Utils.isFunction(this.props.onChange)) { - this.props.onChange(index, prevSelectedIndex); + private renderTabPanel = (tab: TabElement) => { + const { className, panel, id } = tab.props; + if (panel === undefined) { + return undefined; } - } -} + return ( +
+ {panel} +
+ ); + }; -let globalTabCount = 0; -function generateTabId() { - return `pt-tab-${globalTabCount++}`; + private renderTabTitle = (tab: TabElement) => { + const { id } = tab.props; + return ( + + ); + }; } -let globalPanelCount = 0; -function generatePanelId() { - return `pt-tab-panel-${globalPanelCount++}`; +export const TabsFactory = React.createFactory(Tabs); + +function isEventKeyCode(e: React.KeyboardEvent, ...codes: number[]) { + return codes.indexOf(e.which) >= 0; } -export const TabsFactory = React.createFactory(Tabs); +function isTab(child: React.ReactChild): child is TabElement { + return child != null && (child as JSX.Element).type === Tab; +} diff --git a/packages/core/src/components/tabs2/tab2.tsx b/packages/core/src/components/tabs2/tab2.tsx deleted file mode 100644 index 4f4bc579cd..0000000000 --- a/packages/core/src/components/tabs2/tab2.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2017 Palantir Technologies, Inc. All rights reserved. - * - * Licensed under the terms of the LICENSE file distributed with this project. - */ - -import * as classNames from "classnames"; -import * as React from "react"; - -import * as Classes from "../../common/classes"; -import { IProps } from "../../common/props"; - -export type TabId = string | number; - -export interface ITab2Props extends IProps { - /** - * Whether the tab is disabled. - * @default false - */ - disabled?: boolean; - - /** - * Unique identifier used to control which tab is selected - * and to generate ARIA attributes for accessibility. - */ - id: TabId; - - /** - * Panel content, rendered by the parent `Tabs` when this tab is active. - * If omitted, no panel will be rendered for this tab. - */ - panel?: JSX.Element; - - /** - * Content of tab title element, rendered in a list above the active panel. - * Can also be set via React `children`. - */ - title?: string | JSX.Element; -} - -export class Tab2 extends React.PureComponent { - public static defaultProps: ITab2Props = { - disabled: false, - id: undefined, - }; - - public static displayName = "Blueprint.Tab2"; - - // this component is never rendered directly; see Tabs2#renderTabPanel() - /* istanbul ignore next */ - public render() { - const { className, panel } = this.props; - return ( -
- {panel} -
- ); - } -} - -export const Tab2Factory = React.createFactory(Tab2); diff --git a/packages/core/src/components/tabs2/tabs2.md b/packages/core/src/components/tabs2/tabs2.md deleted file mode 100644 index 52c3745f29..0000000000 --- a/packages/core/src/components/tabs2/tabs2.md +++ /dev/null @@ -1,78 +0,0 @@ -@# Tabs2 - -Tabs allow the user to switch between panels of content. - -@## CSS API - -In addition to the [JavaScript API](#core/components/tabs2.javascript-api), Blueprint also offers tab styles with the -class `pt-tabs`. You should add the proper accessibility attributes (`role`, `aria-selected`, and -`aria-hidden`) if you choose to implement tabs with CSS. - -`.pt-tab-panel` elements with `aria-hidden="true"` are hidden automatically by the Blueprint CSS. -You may also simply omit hidden tabs from your markup to improve performance (the `Tabs` -JavaScript component does this by default). - -@css pt-tabs - -@## JavaScript API - -
-
Original `Tabs` API is deprecated since v1.11.0
- The original `Tabs` API has been deprecated in v1.11.0 favor of the simpler and more flexible - `Tabs2` API described below. Documentation for the deprecated components can be found - [further below](#core/components/tabs.deprecated-javascript-api). - This API will replace the deprecated one in v2.0. -
- -
-
Advantages of new API
-

Only two components (`Tabs` and `Tab`) are needed, rather than the previous four.

-

Selection is managed by ID, rather than by index. This is more reliable and deterministic and - does not require translating between numbers and tab names. It does, however, require that - every `Tab` have a locally unique `id` prop.

-

Arbitrary elements are supported in the tab list, and order is respected. Yes, you can even - insert things _between_ `Tab`s.

-
- -The `Tabs2` and `Tab2` components are available in the __@blueprintjs/core__ -package. Make sure to review the [general usage docs for JS components](#blueprint.usage). - -```tsx -import { Tab2, Tabs2 } from "@blueprintjs/core"; - - - } /> - } /> - } /> - } /> - - - -``` - -@reactExample Tabs2Example - -@### Tabs2 - -`Tabs2` is responsible for rendering the tab list and coordinating selection. It can be used in -controlled mode by providing `selectedTabId` and `onChange` props, or in uncontrolled mode by -optionally providing `defaultSelectedTabId` and `onChange`. - -Children of the `Tabs2` are rendered in order in the tab list, which is a horizontal flex row. -`Tab2` children are managed by the component; clicking one will change selection. Arbitrary other -children are simply rendered; interactions are your responsibility. Insert a `` -between any two children to right-align all subsequent children (or bottom-align when `vertical`). - -@interface ITabs2Props - -@### Tab2 - -`Tab2` is a minimal wrapper with no functionality of its own—it is managed entirely by its -parent `Tabs2` wrapper. 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 `panel` is perfectly -safe and allows you to control exactly where the panel appears in the DOM (by rendering it yourself -as needed). - -@interface ITab2Props diff --git a/packages/core/src/components/tabs2/tabs2.tsx b/packages/core/src/components/tabs2/tabs2.tsx deleted file mode 100644 index 8bd24226d9..0000000000 --- a/packages/core/src/components/tabs2/tabs2.tsx +++ /dev/null @@ -1,322 +0,0 @@ -/* - * Copyright 2015 Palantir Technologies, Inc. All rights reserved. - * - * Licensed under the terms of the LICENSE file distributed with this project. - */ - -import * as classNames from "classnames"; -import * as React from "react"; - -import { AbstractPureComponent } from "../../common/abstractPureComponent"; -import * as Classes from "../../common/classes"; -import * as Keys from "../../common/keys"; -import { IProps } from "../../common/props"; -import * as Utils from "../../common/utils"; - -import { ITab2Props, Tab2, TabId } from "./tab2"; -import { generateTabPanelId, generateTabTitleId, TabTitle } from "./tabTitle"; - -export const Expander: React.SFC<{}> = () =>
; - -type TabElement = React.ReactElement; - -const TAB_SELECTOR = `.${Classes.TAB}`; - -export interface ITabs2Props extends IProps { - /** - * Whether the selected tab indicator should animate its movement. - * @default true - */ - animate?: boolean; - - /** - * Initial selected tab `id`, for uncontrolled usage. - * Note that this prop refers only to `` children; other types of elements are ignored. - * @default first tab - */ - defaultSelectedTabId?: TabId; - - /** - * Unique identifier for this `Tabs` container. This will be combined with the `id` of each - * `Tab` child to generate ARIA accessibility attributes. IDs are required and should be - * unique on the page to support server-side rendering. - */ - id: TabId; - - /** - * If set to `true`, the tabs will display with larger styling. - * This is equivalent to setting `pt-large` on the `.pt-tab-list` element. - * This will apply large styles only to the tabs at this level, not to nested tabs. - * @default false - */ - large?: boolean; - - /** - * Whether inactive tab panels should be removed from the DOM and unmounted in React. - * This can be a performance enhancement when rendering many complex panels, but requires - * careful support for unmounting and remounting. - * @default false - */ - renderActiveTabPanelOnly?: boolean; - - /** - * Selected tab `id`, for controlled usage. - * Providing this prop will put the component in controlled mode. - * Unknown ids will result in empty selection (no errors). - */ - selectedTabId?: TabId; - - /** - * Whether to show tabs stacked vertically on the left side. - * @default false - */ - vertical?: boolean; - - /** - * A callback function that is invoked when a tab in the tab list is clicked. - */ - onChange?(newTabId: TabId, prevTabId: TabId, event: React.MouseEvent): void; -} - -export interface ITabs2State { - indicatorWrapperStyle?: React.CSSProperties; - selectedTabId?: TabId; -} - -export class Tabs2 extends AbstractPureComponent { - /** Insert a `Tabs2.Expander` between any two children to right-align all subsequent children. */ - public static Expander = Expander; - - public static Tab = Tab2; - - public static defaultProps: Partial = { - animate: true, - large: false, - renderActiveTabPanelOnly: false, - vertical: false, - }; - - public static displayName = "Blueprint.Tabs2"; - - private tablistElement: HTMLDivElement; - private refHandlers = { - tablist: (tabElement: HTMLDivElement) => (this.tablistElement = tabElement), - }; - - constructor(props?: ITabs2Props) { - super(props); - const selectedTabId = this.getInitialSelectedTabId(); - this.state = { selectedTabId }; - } - - public render() { - const { indicatorWrapperStyle, selectedTabId } = this.state; - - const tabTitles = React.Children.map( - this.props.children, - child => (isTab(child) ? this.renderTabTitle(child) : child), - ); - - const tabPanels = this.getTabChildren() - .filter(this.props.renderActiveTabPanelOnly ? tab => tab.props.id === selectedTabId : () => true) - .map(this.renderTabPanel); - - const tabIndicator = this.props.animate ? ( -
-
-
- ) : ( - undefined - ); - - const classes = classNames(Classes.TABS, { [Classes.VERTICAL]: this.props.vertical }, this.props.className); - const tabListClasses = classNames(Classes.TAB_LIST, { - [Classes.LARGE]: this.props.large, - }); - - return ( -
-
- {tabIndicator} - {tabTitles} -
- {tabPanels} -
- ); - } - - public componentDidMount() { - this.moveSelectionIndicator(); - } - - public componentWillReceiveProps({ selectedTabId }: ITabs2Props) { - if (selectedTabId !== undefined) { - // keep state in sync with controlled prop, so state is canonical source of truth - this.setState({ selectedTabId }); - } - } - - public componentDidUpdate(prevProps: ITabs2Props, prevState: ITabs2State) { - if (this.state.selectedTabId !== prevState.selectedTabId) { - this.moveSelectionIndicator(); - } else if (prevState.selectedTabId != null) { - // comparing React nodes is difficult to do with simple logic, so - // shallowly compare just their props as a workaround. - const didChildrenChange = !Utils.arraysEqual( - this.getTabChildrenProps(prevProps), - this.getTabChildrenProps(), - Utils.shallowCompareKeys, - ); - if (didChildrenChange) { - this.moveSelectionIndicator(); - } - } - } - - private getInitialSelectedTabId() { - // NOTE: providing an unknown ID will hide the selection - const { defaultSelectedTabId, selectedTabId } = this.props; - if (selectedTabId !== undefined) { - return selectedTabId; - } else if (defaultSelectedTabId !== undefined) { - return defaultSelectedTabId; - } else { - // select first tab in absence of user input - const tabs = this.getTabChildren(); - return tabs.length === 0 ? undefined : tabs[0].props.id; - } - } - - private getKeyCodeDirection(e: React.KeyboardEvent) { - if (isEventKeyCode(e, Keys.ARROW_LEFT, Keys.ARROW_UP)) { - return -1; - } else if (isEventKeyCode(e, Keys.ARROW_RIGHT, Keys.ARROW_DOWN)) { - return 1; - } - return undefined; - } - - private getTabChildrenProps(props: ITabs2Props & { children?: React.ReactNode } = this.props) { - return this.getTabChildren(props).map(child => child.props); - } - - /** Filters children to only ``s */ - private getTabChildren(props: ITabs2Props & { children?: React.ReactNode } = this.props) { - return React.Children.toArray(props.children).filter(isTab) as TabElement[]; - } - - /** Queries root HTML element for all `.pt-tab`s with optional filter selector */ - private getTabElements(subselector = "") { - if (this.tablistElement == null) { - return [] as Elements; - } - return this.tablistElement.queryAll(TAB_SELECTOR + subselector); - } - - private handleKeyDown = (e: React.KeyboardEvent) => { - const focusedElement = document.activeElement.closest(TAB_SELECTOR); - // rest of this is potentially expensive and futile, so bail if no tab is focused - if (focusedElement == null) { - return; - } - - // must rely on DOM state because we have no way of mapping `focusedElement` to a JSX.Element - const enabledTabElements = this.getTabElements().filter(el => el.getAttribute("aria-disabled") === "false"); - const focusedIndex = enabledTabElements.indexOf(focusedElement); - const direction = this.getKeyCodeDirection(e); - - if (focusedIndex >= 0 && direction !== undefined) { - e.preventDefault(); - const { length } = enabledTabElements; - // auto-wrapping at 0 and `length` - const nextFocusedIndex = (focusedIndex + direction + length) % length; - (enabledTabElements[nextFocusedIndex] as HTMLElement).focus(); - } - }; - - private handleKeyPress = (e: React.KeyboardEvent) => { - const targetTabElement = (e.target as HTMLElement).closest(TAB_SELECTOR) as HTMLElement; - if (targetTabElement != null && isEventKeyCode(e, Keys.SPACE, Keys.ENTER)) { - e.preventDefault(); - targetTabElement.click(); - } - }; - - private handleTabClick = (newTabId: TabId, event: React.MouseEvent) => { - Utils.safeInvoke(this.props.onChange, newTabId, this.state.selectedTabId, event); - if (this.props.selectedTabId === undefined) { - this.setState({ selectedTabId: newTabId }); - } - }; - - /** - * Calculate the new height, width, and position of the tab indicator. - * Store the CSS values so the transition animation can start. - */ - private moveSelectionIndicator() { - if (this.tablistElement === undefined || !this.props.animate) { - return; - } - - const tabIdSelector = `${TAB_SELECTOR}[data-tab-id="${this.state.selectedTabId}"]`; - const selectedTabElement = this.tablistElement.query(tabIdSelector) as HTMLElement; - - let indicatorWrapperStyle: React.CSSProperties = { display: "none" }; - if (selectedTabElement != null) { - const { clientHeight, clientWidth, offsetLeft, offsetTop } = selectedTabElement; - indicatorWrapperStyle = { - height: clientHeight, - transform: `translateX(${Math.floor(offsetLeft)}px) translateY(${Math.floor(offsetTop)}px)`, - width: clientWidth, - }; - } - this.setState({ indicatorWrapperStyle }); - } - - private renderTabPanel = (tab: TabElement) => { - const { className, panel, id } = tab.props; - if (panel === undefined) { - return undefined; - } - return ( -
- {panel} -
- ); - }; - - private renderTabTitle = (tab: TabElement) => { - const { id } = tab.props; - return ( - - ); - }; -} - -export const Tabs2Factory = React.createFactory(Tabs2); - -function isEventKeyCode(e: React.KeyboardEvent, ...codes: number[]) { - return codes.indexOf(e.which) >= 0; -} - -function isTab(child: React.ReactChild): child is TabElement { - return child != null && (child as JSX.Element).type === Tab2; -} diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts index 90d89da2ff..bd11ae932a 100644 --- a/packages/core/test/index.ts +++ b/packages/core/test/index.ts @@ -43,8 +43,7 @@ import "./progress/progressBarTests"; import "./slider/rangeSliderTests"; import "./slider/sliderTests"; import "./spinner/spinnerTests"; -// import "./tabs/tabsTests"; -import "./tabs2/tabs2Tests"; +import "./tabs/tabsTests"; import "./tag/tagTests"; import "./text/textTests"; import "./toast/toasterTests"; diff --git a/packages/core/test/isotest.js b/packages/core/test/isotest.js index a73447d06d..2356ecc702 100644 --- a/packages/core/test/isotest.js +++ b/packages/core/test/isotest.js @@ -24,7 +24,7 @@ const customChildren = { Popover2: popoverTarget, SVGPopover: popoverTarget, SVGTooltip: popoverTarget, - Tabs2: [Core.Tab2Factory({ key: 1, id: 1, title: "Tab one" })], + Tabs: React.createElement(Core.Tab, { key: 1, id: 1, title: "Tab one" }), Tooltip: popoverTarget, Tooltip2: popoverTarget, }; diff --git a/packages/core/test/tabs/tabsTests.tsx b/packages/core/test/tabs/tabsTests.tsx index a36cb127c8..3cd1d3a9e7 100644 --- a/packages/core/test/tabs/tabsTests.tsx +++ b/packages/core/test/tabs/tabsTests.tsx @@ -3,20 +3,27 @@ * Licensed under the terms of the LICENSE file distributed with this project. */ -// tslint:disable max-classes-per-file - import { assert } from "chai"; import { mount, ReactWrapper } from "enzyme"; import * as React from "react"; import * as ReactDOM from "react-dom"; import { spy } from "sinon"; -import * as Errors from "../../src/common/errors"; +import * as Classes from "../../src/common/classes"; import * as Keys from "../../src/common/keys"; -import { Tab, TabList, TabPanel, Tabs } from "../../src/index"; +import { Tab } from "../../src/components/tabs/tab"; +import { ITabsProps, ITabsState, Tabs } from "../../src/components/tabs/tabs"; + +describe("", () => { + const ID = "tabsTests"; + // default tabs content is generated from these IDs in each test + const TAB_IDS = ["first", "second", "third"]; + + // selectors using ARIA role + const TAB = "[role='tab']"; + const TAB_LIST = "[role='tablist']"; + const TAB_PANEL = "[role='tabpanel']"; -// Skipping tests since this component is deprecated -describe.skip("", () => { let testsContainerElement: HTMLElement; beforeEach(() => { @@ -26,295 +33,335 @@ describe.skip("", () => { afterEach(() => testsContainerElement.remove()); - it("renders its template", () => { - const wrapper = mount({getTabsContents()}); + it("gets by without children", () => { + assert.doesNotThrow(() => mount()); + }); + + it("supports non-existent children", () => { + assert.doesNotThrow(() => + mount( + + {null} + + {undefined} + + , + ), + ); + }); + + it("default selectedTabId is first non-null Tab id", () => { + const wrapper = mount( + + {null} + {