diff --git a/package.json b/package.json index 0dd5c5b65..2907d98eb 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,14 @@ }, "devDependencies": { "@babel/cli": "^7.1.5", + "@types/enzyme": "^3.1.15", + "@types/enzyme-adapter-react-16": "^1.0.3", "@types/jest": "^23.3.10", "@types/node": "^10.12.12", "@types/react": "^16.7.13", "@types/react-dom": "^16.0.11", "@types/react-router-dom": "^4.3.1", + "@types/react-test-renderer": "^16.0.3", "enzyme": "^3.7.0", "enzyme-adapter-react-16": "^1.7.0", "fiori-fundamentals": "^1.3.3", diff --git a/src/Tabs/Tab.tsx b/src/Tabs/Tab.tsx new file mode 100644 index 000000000..ee33f24b6 --- /dev/null +++ b/src/Tabs/Tab.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { ReactNode } from 'react'; +import { TabKey } from "./Tabs"; +import { StatelessComponent } from 'enzyme'; + +export interface TabProps { + /** + * A unique (per Tabs component) identifier for the tab. + */ + key: TabKey; + + /** + * The content to render in the tab title + */ + title: ReactNode; + + /** + * If the tab is disabled. If disabled, it cannot be activated. + */ + disabled?: boolean; + + /** + * The tab content + */ + children?: ReactNode; +} + +/** + * A Tab to render within the Tabs component. + */ +export const Tab: StatelessComponent = (props: TabProps) => ( + + {props.children || null} + +); \ No newline at end of file diff --git a/src/Tabs/TabHeader.tsx b/src/Tabs/TabHeader.tsx new file mode 100644 index 000000000..95a2fccd5 --- /dev/null +++ b/src/Tabs/TabHeader.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { ReactNode } from 'react'; +import { TabProps } from './Tab'; +import { TabKey } from './Tabs'; + +export interface TabHeaderProps { + tabs: React.ReactElement[]; + selectedTabKey?: string; + onSelectTab(key: TabKey): any; +} + +export interface TabHeaderItemProps { + tabKey: string; + title: ReactNode; + active: boolean; + disabled: boolean; + onSelectTab(key: TabKey): any; +} + +export class TabHeaderItem extends React.Component { + render() { + const { disabled, title, active } = this.props; + const classes = ['fd-tabs__link']; + if (active) { + classes.push('is-selected'); + } + return ( +
  • + `${a} ${b}`, '')} + aria-disabled={disabled} + onClick={this.onClicked} + > + {title} + +
  • + ) + } + + private onClicked = () => { + const { disabled, tabKey, onSelectTab } = this.props; + if (!disabled) { + onSelectTab(tabKey); + } + } +} + +export const TabHeader = (props: TabHeaderProps) => { + return ( +
      + { + props.tabs.map(tab => ( + + )) + } +
    + ); +} \ No newline at end of file diff --git a/src/Tabs/Tabs.Component.js b/src/Tabs/Tabs.Component.js deleted file mode 100644 index 8bf237141..000000000 --- a/src/Tabs/Tabs.Component.js +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import { Tabs, TabComponent } from '../'; -import { DocsTile, DocsText, Separator, Header, Description, Import, Properties, Playground } from '../'; - -export const TabsComponent = () => { - const tabscomponentCode = ` - - - - `; - - return ( -
    -
    Tabs
    - - Tabs are based on a folder metaphor and used to separate content into different sections. Tabs should be - ordered to create a visual hierarchy based on priority. - - - - - - - - - {tabscomponentCode} - -

    Playground

    - - - - - -
    - ); -}; diff --git a/src/Tabs/Tabs.Component.tsx b/src/Tabs/Tabs.Component.tsx new file mode 100644 index 000000000..86ff8e8d8 --- /dev/null +++ b/src/Tabs/Tabs.Component.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Tabs, Tab } from '.'; +import { DocsTile, DocsText, Separator, Header, Description, Import, Properties, Playground } from '..'; + +export const TabsComponent = () => { + const tabscomponentCode = ` + + + + + `; + + return ( +
    +
    Tabs
    + + Tabs are based on a folder metaphor and used to separate content into different sections. Tabs should be + ordered to create a visual hierarchy based on priority. + + + + + + Hello World + Hello World 2 + Hello World 3 + + + {tabscomponentCode} + +
    + ); +}; diff --git a/src/Tabs/Tabs.js b/src/Tabs/Tabs.js deleted file mode 100644 index 854992a1f..000000000 --- a/src/Tabs/Tabs.js +++ /dev/null @@ -1,70 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { BrowserRouter, Link } from 'react-router-dom'; - -export const Tabs = props => { - const { children } = props; - return
      {children}
    ; -}; - -export class TabComponent extends Component { - constructor(props) { - super(props); - let initialStates = []; - - initialStates = props.ids.forEach(ids => { - let obj = {}; - let id = ids.id; - obj[id] = false; - return obj; - }); - this.state = { - selectedTab: '1', - tabStates: initialStates - }; - - this.handleTabSelection = this.handleTabSelection.bind(this); - } - - handleTabSelection(e, id) { - let iStates = Object.assign({}, this.state.tabStates); - iStates[id.id] = !iStates[id.id]; - this.setState({ tabStates: iStates }); - this.setState({ selectedTab: id.id }); - } - - render() { - const { ids } = this.props; - return ( - -
      - {ids.map(id => { - return ( -
    • - { - !id.disabled && this.handleTabSelection(e, id, id.disabled); - }} - > - {id.name} - - {this.state.selectedTab === id.id ? ( -

      {id.content}

      - ) : null} -
    • - ); - })} -
    -
    - ); - } -} - -TabComponent.propTypes = { - ids: PropTypes.array.isRequired -}; diff --git a/src/Tabs/Tabs.test.js b/src/Tabs/Tabs.test.js deleted file mode 100644 index 483b89e18..000000000 --- a/src/Tabs/Tabs.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import renderer from 'react-test-renderer'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import { Tabs, TabComponent } from './Tabs'; - -Enzyme.configure({ adapter: new Adapter() }); - -describe('', () => { - const tabComponent = ( - - ); - - const defaultTabs = {tabComponent}; - - test('create tabs component', () => { - let component = renderer.create(defaultTabs); - let tree = component.toJSON(); - expect(tree).toMatchSnapshot(); - - component = renderer.create(tabComponent); - tree = component.toJSON(); - expect(tree).toMatchSnapshot(); - }); - - test('tab selection', () => { - const wrapper = mount(tabComponent); - - // check selected tab - expect(wrapper.state(['selectedTab'])).toEqual('1'); - - wrapper - .find('ul.fd-tabs li.fd-tabs__item a.fd-tabs__link') - .at(1) - .simulate('click'); - - // check selected tab changed - expect(wrapper.state(['selectedTab'])).toEqual('2'); - }); -}); diff --git a/src/Tabs/Tabs.test.tsx b/src/Tabs/Tabs.test.tsx new file mode 100644 index 000000000..d59574c21 --- /dev/null +++ b/src/Tabs/Tabs.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import Enzyme, { mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { Tabs, Tab } from './index'; + +import { writeFile } from 'fs'; +import { JsxEmit } from 'typescript'; + +Enzyme.configure({ adapter: new Adapter() }); + +describe('', () => { + function getTabs(additionalProps = {}) { + return ( + + + Hello World + + + Hello World 2 + + + Hello World + + + ); + } + + test('create tabs component', () => { + const tabs = getTabs(); + let component = renderer.create(tabs); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + + component = renderer.create(tabs); + tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + test('tab selection', () => { + const onChangeFn = jest.fn(); + const wrapper = mount(getTabs({ onChange: onChangeFn })); + + expect((wrapper.state() as any)['activeKey']).toEqual('1'); + + wrapper + .find('ul.fd-tabs li.fd-tabs__item a.fd-tabs__link') + .at(1) + .simulate('click'); + + // check selected tab changed + expect((wrapper.state() as any).activeKey).toEqual('2'); + expect(onChangeFn).toHaveBeenCalledWith('2', '1'); + }); +}); diff --git a/src/Tabs/Tabs.tsx b/src/Tabs/Tabs.tsx new file mode 100644 index 000000000..ae359b535 --- /dev/null +++ b/src/Tabs/Tabs.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { ReactElement } from 'react'; +import { TabProps } from './Tab'; +import { TabHeader } from './TabHeader'; + +export type TabKey = string; + +export interface TabsProps { + /** + * Set the active tab. + * If the active tab isn't set externally the component + * will behave as an uncontrolled component. + */ + activeKey?: TabKey; + /** + * The content of the tabs component. + * Only Tab elements are allowed as children. + */ + children?: ReactElement[] | ReactElement + + /** + * Callback that is invoked whenever the active tab changes. + * @param selectedKey The newly selected key. + */ + onChange?(selectedKey: TabKey, previousSelectedKey?: TabKey): any; +} + +interface TabsState { + activeKey?: TabKey; +} + +/** + * Component that renders react-fundamental tabs. + * It can either be used as a controlled component (by passing the active key as props and handling the callback) + * or as an uncontrolled component (in which case the state is managed internal to the component). + * + * Not implemented yet: + * * Overflow display (if the number of tabs exceeds the size of the tab container) + */ +export class Tabs extends React.PureComponent { + constructor(props: TabsProps) { + super(props); + // If no active key is passed in props, set the key of the first child as active key. + this.state = { + activeKey: props.activeKey || (this.childrenAsArray(props.children)[0] || {})['key'] as any + } + } + + render() { + return ( + + +
    + {this.activeTabContent()} +
    +
    + + ); + } + + private childrenAsArray = ( + children: React.ReactElement[] | React.ReactElement | undefined = this.props.children + ): ReactElement[] => { + + if (Array.isArray(children)) { + return children; + } + if (children) { + return [children]; + } + return []; + }; + + private selectedTab: () => (TabKey | undefined) = () => { + if (this.props.activeKey !== undefined) { + return this.props.activeKey; + } + return this.state.activeKey; + }; + private activeTabContent = () => { + return this.childrenAsArray() + .filter(child => (child as any).key === this.selectedTab()) || null; + } + + private onTabSelected = (key: TabKey) => { + if (this.props.onChange) { + this.props.onChange(key, this.selectedTab()); + } + this.setState({ + activeKey: key + }); + } +} + diff --git a/src/Tabs/__snapshots__/Tabs.test.js.snap b/src/Tabs/__snapshots__/Tabs.test.js.snap index 39abeb6ca..cce5f80c3 100644 --- a/src/Tabs/__snapshots__/Tabs.test.js.snap +++ b/src/Tabs/__snapshots__/Tabs.test.js.snap @@ -1,101 +1,85 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` create tabs component 1`] = ` - + , +
    + Hello World +
    , +] `; exports[` create tabs component 2`] = ` -, +
    - - Tab 3 - - - + Hello World +
    , +] `; diff --git a/src/Tabs/index.ts b/src/Tabs/index.ts new file mode 100644 index 000000000..b9b880ac6 --- /dev/null +++ b/src/Tabs/index.ts @@ -0,0 +1,2 @@ +export { Tab } from './Tab'; +export { Tabs } from './Tabs'; \ No newline at end of file diff --git a/src/documentation/Playground/Playground.js b/src/documentation/Playground/Playground.js index 33b3d8b7a..c279ebb94 100644 --- a/src/documentation/Playground/Playground.js +++ b/src/documentation/Playground/Playground.js @@ -6,7 +6,7 @@ import { Dropdown } from '../../'; import { Icon } from '../../'; import { Identifier } from '../../'; import { Image } from '../../'; -import { Tabs, TabComponent } from '../../'; +import { Tabs } from '../../'; import { FormGroup, FormLabel, FormItem, InputGroup } from '../../'; import { ListGroup, ListGroupItem, ListGroupItemActions } from '../../'; import { Tile, TileContent, TileMedia, TileActions, ProductTile, ProductTileContent, ProductTileMedia } from '../../'; @@ -393,7 +393,7 @@ export class Playground extends Component { case 'tabs': componentToGenerate = ( - + {this.state.childs.children} ); break; diff --git a/src/index.tsx b/src/index.tsx index 7c3c6df39..3fb76e8f2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -76,7 +76,7 @@ import { SideNavGroup } from '../src/SideNavigation/SideNavigation'; import { Table } from '../src/Table/Table'; -import { Tabs, TabComponent } from '../src/Tabs/Tabs'; +import { Tabs, Tab } from '../src/Tabs'; import { Token } from './Token/Token'; import { Tile, @@ -176,7 +176,7 @@ export { SideNavGroup, Table, Tabs, - TabComponent, + Tab, Token, Tile, TileContent,