From 53897323bb9736999bed0df7962367e501070abc Mon Sep 17 00:00:00 2001 From: Peter Pal Hudak Date: Wed, 18 Oct 2023 13:46:08 +0200 Subject: [PATCH] feat(ui-tabs): add active property to tabs Closes: INSTUI-3876 Add support for multiple tabs->one tab panel. This makes using React Router easier with less boilerplate code. --- package-lock.json | 4 +- packages/ui-tabs/package.json | 4 +- packages/ui-tabs/src/Tabs/Panel/index.tsx | 4 +- packages/ui-tabs/src/Tabs/Panel/props.ts | 11 +- packages/ui-tabs/src/Tabs/README.md | 71 ++++++++++ .../src/Tabs/__new-tests__/Tabs.test.tsx | 128 ++++++++++++++++++ packages/ui-tabs/src/Tabs/index.tsx | 51 +++++-- 7 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 packages/ui-tabs/src/Tabs/__new-tests__/Tabs.test.tsx diff --git a/package-lock.json b/package-lock.json index 5769000c0c..56709977b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53134,7 +53134,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" }, "peerDependencies": { "react": ">=16.8 <=18" diff --git a/packages/ui-tabs/package.json b/packages/ui-tabs/package.json index ac37ce239f..3ba299032b 100644 --- a/packages/ui-tabs/package.json +++ b/packages/ui-tabs/package.json @@ -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", diff --git a/packages/ui-tabs/src/Tabs/Panel/index.tsx b/packages/ui-tabs/src/Tabs/Panel/index.tsx index 9520ce4732..23cbf1368c 100644 --- a/packages/ui-tabs/src/Tabs/Panel/index.tsx +++ b/packages/ui-tabs/src/Tabs/Panel/index.tsx @@ -55,7 +55,8 @@ class Panel extends Component { textAlign: 'start', variant: 'default', isSelected: false, - padding: 'small' + padding: 'small', + active: false } componentDidMount() { @@ -92,6 +93,7 @@ class Panel extends Component { isDisabled, isSelected, styles, + active, ...props } = this.props const isHidden = !isSelected || !!isDisabled diff --git a/packages/ui-tabs/src/Tabs/Panel/props.ts b/packages/ui-tabs/src/Tabs/Panel/props.ts index e7f3d0af80..f2ec364e0b 100644 --- a/packages/ui-tabs/src/Tabs/Panel/props.ts +++ b/packages/ui-tabs/src/Tabs/Panel/props.ts @@ -58,6 +58,11 @@ type TabsPanelOwnProps = { * provides a reference to the underlying html root element */ elementRef?: (element: HTMLDivElement | null) => void + /** + * Only one `` can be marked as active. The marked panel's content is rendered + * for all the ``s. + */ + active?: boolean } type PropKeys = keyof TabsPanelOwnProps @@ -82,7 +87,8 @@ const propTypes: PropValidators = { labelledBy: PropTypes.string, padding: ThemeablePropTypes.spacing, textAlign: PropTypes.oneOf(['start', 'center', 'end']), - elementRef: PropTypes.func + elementRef: PropTypes.func, + active: PropTypes.bool } const allowedProps: AllowedPropKeys = [ @@ -97,7 +103,8 @@ const allowedProps: AllowedPropKeys = [ 'labelledBy', 'padding', 'textAlign', - 'elementRef' + 'elementRef', + 'active' ] export type { TabsPanelProps, TabsPanelStyle } diff --git a/packages/ui-tabs/src/Tabs/README.md b/packages/ui-tabs/src/Tabs/README.md index 6028457074..0ebce5d8ba 100644 --- a/packages/ui-tabs/src/Tabs/README.md +++ b/packages/ui-tabs/src/Tabs/README.md @@ -256,6 +256,77 @@ class Example extends React.Component { render() ``` +### Support for dynamic content with active panel + +Marking one of the `` 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 ( +
+ + {this.state.show ? 'Hello Developer' : 'Simulating network call...'} + + {this.state.show ? lorem.paragraphs() : + } +
+ ) + } + } + + +class Example extends React.Component { + state = { + selectedIndex: 0 + } + handleTabChange = (event, { index, id }) => { + this.setState({ + selectedIndex: index + }) + } + + render() { + const { selectedIndex } = this.state + return ( + + + + + + + + + ) + } +} + +render() +``` + ### Guidelines ```js diff --git a/packages/ui-tabs/src/Tabs/__new-tests__/Tabs.test.tsx b/packages/ui-tabs/src/Tabs/__new-tests__/Tabs.test.tsx new file mode 100644 index 0000000000..95cdc1724f --- /dev/null +++ b/packages/ui-tabs/src/Tabs/__new-tests__/Tabs.test.tsx @@ -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 ( + { + setSelectedIndex(index) + props.onIndexChange(index) + }} + variant="default" + margin="medium" + > + +

CONTENT

+
+ + +
+ ) +} + +describe('', () => { + it('should render the correct number of panels', () => { + const { container } = render( + + Tab 1 content + Tab 2 content + + Tab 3 content + + + ) + + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render same content for other tabs as for the active one', () => { + const { container } = render( + + + CONTENT + + + Child + + Child + + ) + + 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', () => { + const onIndexChange = jest.fn() + + const { container } = render() + 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( + + + Tab 1 content + + + Tab 2 content + + + Tab 3 content + + + ) + + expect(container.firstChild).toBeInTheDocument() + + expect(consoleMock.mock.calls[0][0]).toEqual( + 'Warning: [Tabs] Only one Panel can be marked as active.' + ) + }) +}) diff --git a/packages/ui-tabs/src/Tabs/index.tsx b/packages/ui-tabs/src/Tabs/index.tsx index 48af2ac5b7..6fbf02ae01 100644 --- a/packages/ui-tabs/src/Tabs/index.tsx +++ b/packages/ui-tabs/src/Tabs/index.tsx @@ -27,7 +27,8 @@ import React, { Component, ComponentClass, ComponentElement, - createElement + createElement, + ReactElement } from 'react' import keycode from 'keycode' @@ -359,24 +360,44 @@ class Tabs extends Component { _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) => { @@ -430,6 +451,14 @@ class Tabs extends Component { ...props } = this.props + const activePanels = (React.Children.toArray(children) as PanelChild[]) + .filter((child) => matchComponentTypes(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[] ) @@ -447,7 +476,13 @@ class Tabs extends Component { 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 {