Skip to content

Commit

Permalink
feat(ui-tabs): add active property to tabs
Browse files Browse the repository at this point in the history
Closes: INSTUI-3876

Add support for multiple tabs->one tab panel. This makes
using React Router easier with less boilerplate code.
  • Loading branch information
joyenjoyer committed Oct 20, 2023
1 parent 56c7c38 commit 3c4e72f
Show file tree
Hide file tree
Showing 7 changed files with 225 additions and 13 deletions.
6 changes: 5 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/ui-tabs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"@instructure/ui-color-utils": "8.46.1",
"@instructure/ui-test-locator": "8.46.1",
"@instructure/ui-test-utils": "8.46.1",
"@instructure/ui-themes": "8.46.1"
"@instructure/ui-themes": "8.46.1",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.0.0"
},
"dependencies": {
"@babel/runtime": "^7.23.2",
Expand Down
4 changes: 3 additions & 1 deletion packages/ui-tabs/src/Tabs/Panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ class Panel extends Component<TabsPanelProps> {
textAlign: 'start',
variant: 'default',
isSelected: false,
padding: 'small'
padding: 'small',
active: false
}

componentDidMount() {
Expand Down Expand Up @@ -92,6 +93,7 @@ class Panel extends Component<TabsPanelProps> {
isDisabled,
isSelected,
styles,
active,
...props
} = this.props
const isHidden = !isSelected || !!isDisabled
Expand Down
11 changes: 9 additions & 2 deletions packages/ui-tabs/src/Tabs/Panel/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ type TabsPanelOwnProps = {
* provides a reference to the underlying html root element
*/
elementRef?: (element: HTMLDivElement | null) => void
/**
* Only one `<Tabs.Panel />` can be marked as active. The marked panel's content is rendered
* for all the `<Tabs.Panel />`s.
*/
active?: boolean
}

type PropKeys = keyof TabsPanelOwnProps
Expand All @@ -82,7 +87,8 @@ const propTypes: PropValidators<PropKeys> = {
labelledBy: PropTypes.string,
padding: ThemeablePropTypes.spacing,
textAlign: PropTypes.oneOf(['start', 'center', 'end']),
elementRef: PropTypes.func
elementRef: PropTypes.func,
active: PropTypes.bool
}

const allowedProps: AllowedPropKeys = [
Expand All @@ -97,7 +103,8 @@ const allowedProps: AllowedPropKeys = [
'labelledBy',
'padding',
'textAlign',
'elementRef'
'elementRef',
'active'
]

export type { TabsPanelProps, TabsPanelStyle }
Expand Down
71 changes: 71 additions & 0 deletions packages/ui-tabs/src/Tabs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,77 @@ class Example extends React.Component {
render(<Example />)
```

### Support for dynamic content with active panel

Marking one of the `<Tabs.Panel>` as `active` will render that panel's content in all the panels. This is useful for dynamic content rendering: the panel area can be used as a container, what routing libraries, such as React Router, can use to render their children elements into.

```js
---
example: true
render: false
---
class Outlet extends React.Component {
state = {
show: false
}

componentDidMount() {
setTimeout(() => this.setState({ show: true }), 2000)
}

render() {
return (
<div>
<Heading level='h1' as='h1' margin='0 0 x-small'>
{this.state.show ? 'Hello Developer' : 'Simulating network call...'}
</Heading>
{this.state.show ? lorem.paragraphs() : <Spinner renderTitle='Loading' size='medium' />
}
</div>
)
}
}


class Example extends React.Component {
state = {
selectedIndex: 0
}
handleTabChange = (event, { index, id }) => {
this.setState({
selectedIndex: index
})
}

render() {
const { selectedIndex } = this.state
return (
<Tabs
margin='large auto'
padding='medium'
onRequestTabChange={this.handleTabChange}
>
<Tabs.Panel
id='tabA'
renderTitle='Tab A'
textAlign='center'
padding='large'
isSelected={selectedIndex === 0}
active
>
<Outlet />
</Tabs.Panel>
<Tabs.Panel id='tabB' renderTitle='Disabled Tab' isDisabled />
<Tabs.Panel id='tabC' renderTitle='Tab C' isSelected={selectedIndex === 2} />
<Tabs.Panel id='tabD' renderTitle='Tab D' isSelected={selectedIndex === 3} />
</Tabs>
)
}
}

render(<Example />)
```

### Guidelines

```js
Expand Down
91 changes: 91 additions & 0 deletions packages/ui-tabs/src/Tabs/__new-tests__/Tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* The MIT License (MIT)
*
* Copyright (c) 2015 - present Instructure, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

import React from 'react'

import { Tabs } from '../index'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'

describe('<Tabs />', () => {
it('should render the correct number of panels', () => {
const { container } = render(
<Tabs>
<Tabs.Panel renderTitle="First Tab">Tab 1 content</Tabs.Panel>
<Tabs.Panel renderTitle="Second Tab">Tab 2 content</Tabs.Panel>
<Tabs.Panel renderTitle="Third Tab" isDisabled>
Tab 3 content
</Tabs.Panel>
</Tabs>
)

expect(container.firstChild).toBeInTheDocument()
})

it('should render same content for other tabs as for the active one', () => {
const { container } = render(
<Tabs>
<Tabs.Panel renderTitle="First Tab" active>
CONTENT
</Tabs.Panel>
<Tabs.Panel id="secondTab" renderTitle="Second Tab" isSelected>
Child
</Tabs.Panel>
<Tabs.Panel renderTitle="Third Tab">Child</Tabs.Panel>
</Tabs>
)

const tabContent = screen.getByText('CONTENT')

expect(container).toBeInTheDocument()
expect(tabContent).toBeInTheDocument()

const childContent = screen.queryByText('Child')

expect(childContent).toBeNull()
})

it('should warn if multiple active tabs exist', () => {
const consoleMock = jest.spyOn(console, 'error').mockImplementation()
const { container } = render(
<Tabs>
<Tabs.Panel renderTitle="First Tab" active>
Tab 1 content
</Tabs.Panel>
<Tabs.Panel renderTitle="Second Tab" active>
Tab 2 content
</Tabs.Panel>
<Tabs.Panel renderTitle="Third Tab" isDisabled>
Tab 3 content
</Tabs.Panel>
</Tabs>
)

expect(container.firstChild).toBeInTheDocument()

expect(consoleMock.mock.calls[0][0]).toEqual(
'Warning: [Tabs] Only one Panel can be marked as active.'
)
})
})
51 changes: 43 additions & 8 deletions packages/ui-tabs/src/Tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import React, {
Component,
ComponentClass,
ComponentElement,
createElement
createElement,
ReactElement
} from 'react'

import keycode from 'keycode'
Expand Down Expand Up @@ -359,24 +360,44 @@ class Tabs extends Component<TabsProps, TabsState> {
_index: number,
generatedId: string,
selected: boolean,
panel: PanelChild
panel: PanelChild,
activePanel?: PanelChild
) {
const id = panel.props.id || generatedId

// fixHeight can be 0, so simply `fixheight` could return falsy value
const hasFixedHeight = typeof this.props.fixHeight !== 'undefined'

return safeCloneElement(panel, {
const commonProps = {
id: panel.props.id || `panel-${id}`,
labelledBy: `tab-${id}`,
isSelected: selected,
key: panel.props.id || `panel-${id}`,
variant: this.props.variant,
padding: panel.props.padding || this.props.padding,
textAlign: panel.props.textAlign || this.props.textAlign,
maxHeight: !hasFixedHeight ? this.props.maxHeight : undefined,
minHeight: !hasFixedHeight ? this.props.minHeight : '100%'
} as TabsPanelProps & { key: string }) as PanelChild
}

let activePanelClone = null
if (activePanel !== undefined) {
// cloning active panel with a proper custom key as a workaround because
// safeCloneElement overwrites it with the key from the original element
activePanelClone = React.cloneElement(activePanel as ReactElement, {
key: panel.props.id || `panel-${id}`
})

return safeCloneElement(activePanelClone, {
padding: activePanelClone.props.padding || this.props.padding,
textAlign: activePanelClone.props.textAlign || this.props.textAlign,
...commonProps
} as TabsPanelProps & { key: string }) as PanelChild
} else {
return safeCloneElement(panel, {
key: panel.props.id || `panel-${id}`,
padding: panel.props.padding || this.props.padding,
textAlign: panel.props.textAlign || this.props.textAlign,
...commonProps
} as TabsPanelProps & { key: string }) as PanelChild
}
}

handleFocusableRef = (el: Focusable | null) => {
Expand Down Expand Up @@ -430,6 +451,14 @@ class Tabs extends Component<TabsProps, TabsState> {
...props
} = this.props

const activePanels = (React.Children.toArray(children) as PanelChild[])
.filter((child) => matchComponentTypes<PanelChild>(child, [Panel]))
.filter((child) => child.props.active)

if (activePanels.length > 1) {
error(false, `[Tabs] Only one Panel can be marked as active.`)
}

const selectedChildIndex = (
React.Children.toArray(children) as PanelChild[]
)
Expand All @@ -447,7 +476,13 @@ class Tabs extends Component<TabsProps, TabsState> {
const id = uid()

tabs.push(this.createTab(index, id, selected, child))
panels.push(this.clonePanel(index, id, selected, child))
if (activePanels.length === 1) {
panels.push(
this.clonePanel(index, id, selected, child, activePanels[0])
)
} else {
panels.push(this.clonePanel(index, id, selected, child))
}

index++
} else {
Expand Down

0 comments on commit 3c4e72f

Please sign in to comment.