Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui-tabs): add active property to tabs #1327

Merged
merged 1 commit into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 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
128 changes: 128 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,128 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'

const TabExample = (props: { onIndexChange: (arg: number) => void }) => {
const [selectedIndex, setSelectedIndex] = React.useState(0)
return (
<Tabs
onRequestTabChange={(_event, { index }) => {
setSelectedIndex(index)
props.onIndexChange(index)
}}
variant="default"
margin="medium"
>
<Tabs.Panel renderTitle="First Tab" id="first" isSelected={selectedIndex === 0} active>
<p>CONTENT</p>
</Tabs.Panel>
<Tabs.Panel renderTitle="Second Tab" id="second" isSelected={selectedIndex === 1} />
<Tabs.Panel renderTitle="Third Tab" id="third" isSelected={selectedIndex === 2} />
</Tabs>
)
}

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', () => {
joyenjoyer marked this conversation as resolved.
Show resolved Hide resolved
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 render the same content in second tab when selected', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a test as you requested. @matyasf

const onIndexChange = jest.fn()

const { container } = render(<TabExample onIndexChange={onIndexChange} />)
expect(container).toBeInTheDocument()

const secondTab = screen.getAllByRole('tab')[1]

fireEvent.click(secondTab)

expect(onIndexChange).toHaveBeenCalledWith(1)

const panelContent = screen.queryByText('CONTENT')

expect(panelContent).toBeInTheDocument()
})

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
Loading