diff --git a/packages/react/package.json b/packages/react/package.json index 3249cd1ea899..cd6a35def526 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,6 +34,7 @@ "postinstall": "carbon-telemetry collect --install", "prepublish": "yarn build", "start": "yarn storybook", + "start:v11": "CARBON_ENABLE_V11_RELEASE=true yarn storybook", "storybook": "rimraf node_modules/.cache/storybook && start-storybook -p 9000 -s ./.storybook/assets", "snapshot": "build-storybook && percy-storybook --widths=320,1280", "test:e2e": "cypress run-ct --config video=false,screenshotOnRunFailure=false" diff --git a/packages/react/src/components/Tile/index.js b/packages/react/src/components/Tile/index.js index 941915717a0d..5841eb962766 100644 --- a/packages/react/src/components/Tile/index.js +++ b/packages/react/src/components/Tile/index.js @@ -5,4 +5,45 @@ * LICENSE file in the root directory of this source tree. */ -export * from './Tile'; +import * as FeatureFlags from '@carbon/feature-flags'; +import { + Tile as TileNext, + ClickableTile as ClickableTileNext, + SelectableTile as SelectableTileNext, + TileAboveTheFoldContent as TileAboveTheFoldContentNext, + TileBelowTheFoldContent as TileBelowTheFoldContentNext, +} from './next/Tile'; +import { + Tile as TileClassic, + ClickableTile as ClickableTileClassic, + SelectableTile as SelectableTileClassic, + ExpandableTile, + TileAboveTheFoldContent as TileAboveTheFoldContentClassic, + TileBelowTheFoldContent as TileBelowTheFoldContentClassic, +} from './Tile'; + +export const Tile = FeatureFlags.enabled('enable-v11-release') + ? TileNext + : TileClassic; + +export const ClickableTile = FeatureFlags.enabled('enable-v11-release') + ? ClickableTileNext + : ClickableTileClassic; + +export const SelectableTile = FeatureFlags.enabled('enable-v11-release') + ? SelectableTileNext + : SelectableTileClassic; + +export { ExpandableTile }; + +export const TileAboveTheFoldContent = FeatureFlags.enabled( + 'enable-v11-release' +) + ? TileAboveTheFoldContentNext + : TileAboveTheFoldContentClassic; + +export const TileBelowTheFoldContent = FeatureFlags.enabled( + 'enable-v11-release' +) + ? TileBelowTheFoldContentNext + : TileBelowTheFoldContentClassic; diff --git a/packages/react/src/components/Tile/next/Tile-test.js b/packages/react/src/components/Tile/next/Tile-test.js new file mode 100644 index 000000000000..423fcd72548d --- /dev/null +++ b/packages/react/src/components/Tile/next/Tile-test.js @@ -0,0 +1,126 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { Tile, ClickableTile, SelectableTile } from './Tile'; + +import Link from '../../Link'; +import { render, cleanup, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; + +describe('Default', () => { + afterEach(cleanup); + + it('adds extra classes that are passed via className', () => { + render( + + Default tile +
+
+ Link +
+ ); + + expect(screen.getByText('Default tile').classList.contains('🚀')).toBe( + true + ); + }); +}); + +describe('ClickableTile', () => { + afterEach(cleanup); + + it('renders with a link', () => { + render( + + Clickable Tile + + ); + expect(screen.getByRole('link')).toBeInTheDocument(); + }); +}); + +describe('Multi Select', () => { + afterEach(cleanup); + + it('does not invoke the click handler if SelectableTile is disabled', () => { + const onClick = jest.fn(); + render( +
+ + + 🚦 + + +
+ ); + const tile = screen.getByText('🚦'); + userEvent.click(tile); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should cycle elements in document tab order', () => { + render( +
+ + tile 1 + + + tile 2 + + + tile 3 + +
+ ); + const [id1, id2, id3] = screen.getAllByTestId('element'); + expect(document.body).toHaveFocus(); + + userEvent.tab(); + + expect(id1).toHaveFocus(); + + userEvent.tab(); + + expect(id2).toHaveFocus(); + + userEvent.tab(); + + expect(id3).toHaveFocus(); + + userEvent.tab(); + + // cycle goes back to the body element + expect(document.body).toHaveFocus(); + + userEvent.tab(); + + expect(id1).toHaveFocus(); + }); +}); + +// Todo: Testing for a disabled ClickableTile +// Todo: Testing for ExpandableTile +// Todo: Testing for RadioTile diff --git a/packages/react/src/components/Tile/next/Tile.js b/packages/react/src/components/Tile/next/Tile.js new file mode 100644 index 000000000000..9dd9976b96b3 --- /dev/null +++ b/packages/react/src/components/Tile/next/Tile.js @@ -0,0 +1,704 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { Component, useState } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import Link from '../../Link'; +import { + Checkbox16, + CheckboxCheckedFilled16, + ChevronDown16, +} from '@carbon/icons-react'; +import { keys, matches } from '../../../internal/keyboard'; +import deprecate from '../../../prop-types/deprecate'; +import { composeEventHandlers } from '../../../tools/events'; +import { PrefixContext, usePrefix } from '../../../internal/usePrefix'; + +export const Tile = React.forwardRef(function Tile( + { children, className, light = false, ...rest }, + ref +) { + const prefix = usePrefix(); + + const tileClasses = cx( + `${prefix}--tile`, + { + [`${prefix}--tile--light`]: light, + }, + className + ); + return ( +
+ {children} +
+ ); +}); + +Tile.displayName = 'Tile'; +Tile.propTypes = { + /** + * The child nodes. + */ + children: PropTypes.node, + + /** + * The CSS class names. + */ + className: PropTypes.string, + + /** + * `true` to use the light version. For use on $ui-01 backgrounds only. + * Don't use this to make tile background color same as container background color. + */ + light: deprecate( + PropTypes.bool, + 'The `light` prop for `Tile` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead.' + ), +}; + +export const ClickableTile = React.forwardRef(function ClickableTile( + { + children, + className, + clicked = false, + handleClick, + handleKeyDown, + href, + light = false, + onClick = () => {}, + onKeyDown = () => {}, + ...rest + }, + ref +) { + const prefix = usePrefix(); + + const classes = cx( + `${prefix}--tile`, + `${prefix}--tile--clickable`, + { + [`${prefix}--tile--is-clicked`]: clicked, + [`${prefix}--tile--light`]: light, + }, + className + ); + + const [isSelected, setIsSelected] = useState(clicked); + + // TODO: replace with onClick when handleClick prop is deprecated + const clickHandler = handleClick || onClick; + + // TODO: replace with onClick when handleClick prop is deprecated + const keyDownHandler = handleKeyDown || onKeyDown; + + function handleOnClick(evt) { + evt.persist(); + setIsSelected(!isSelected); + clickHandler(evt); + } + + function handleOnKeyDown(evt) { + evt.persist(); + if (matches(evt, [keys.Enter, keys.Space])) { + evt.preventDefault(); + setIsSelected(!isSelected); + keyDownHandler(evt); + } + keyDownHandler(evt); + } + + return ( + + {children} + + ); +}); + +ClickableTile.displayName = 'ClickableTile'; +ClickableTile.propTypes = { + /** + * The child nodes. + */ + children: PropTypes.node, + + /** + * The CSS class names. + */ + className: PropTypes.string, + + /** + * Boolean for whether a tile has been clicked. + */ + clicked: PropTypes.bool, + + /** + * Deprecated in v11. Use 'onClick' instead. + */ + handleClick: deprecate( + PropTypes.func, + 'The handleClick prop for ClickableTile has been deprecated in favor of onClick. It will be removed in the next major release.' + ), + + /** + * Specify the function to run when the ClickableTile is interacted with via a keyboard + */ + handleKeyDown: deprecate( + PropTypes.func, + 'The handleKeyDown prop for ClickableTile has been deprecated in favor of onKeyDown. It will be removed in the next major release.' + ), + + /** + * The href for the link. + */ + href: PropTypes.string, + + /** + * `true` to use the light version. For use on $ui-01 backgrounds only. + * Don't use this to make tile background color same as container background color. + */ + light: deprecate( + PropTypes.bool, + 'The `light` prop for `Tile` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead.' + ), + + /** + * Specify the function to run when the ClickableTile is clicked + */ + onClick: PropTypes.func, + + /** + * Specify the function to run when the ClickableTile is interacted with via a keyboard + */ + onKeyDown: PropTypes.func, + + /** + * The rel property for the link. + */ + rel: PropTypes.string, +}; + +export const SelectableTile = React.forwardRef(function SelectableTile( + { + children, + className, + disabled, + handleClick, + handleKeyDown, + // TODO: Remove iconDescription prop in the next major release + // eslint-disable-next-line no-unused-vars + iconDescription, + id, + light = false, + name, + onClick = () => {}, + onChange = () => {}, + onKeyDown = () => {}, + selected = false, + tabIndex = 0, + title = 'title', + value = 'value', + ...rest + }, + ref +) { + const prefix = usePrefix(); + + // TODO: replace with onClick when handleClick prop is deprecated + const clickHandler = handleClick || onClick; + + // TODO: replace with onKeyDown when handleKeyDown prop is deprecated + const keyDownHandler = handleKeyDown || onKeyDown; + + const [isSelected, setIsSelected] = useState(selected); + const [prevSelected, setPrevSelected] = useState(selected); + + const classes = cx( + `${prefix}--tile`, + `${prefix}--tile--selectable`, + { + [`${prefix}--tile--is-selected`]: isSelected, + [`${prefix}--tile--light`]: light, + [`${prefix}--tile--disabled`]: disabled, + }, + className + ); + const inputClasses = cx(`${prefix}--tile-input`, { + [`${prefix}--tile-input--checked`]: isSelected, + }); + + // TODO: rename to handleClick when handleClick prop is deprecated + function handleOnClick(evt) { + evt.preventDefault(); + evt.persist(); + setIsSelected(!isSelected); + clickHandler(evt); + onChange(evt); + } + + // TODO: rename to handleKeyDown when handleKeyDown prop is deprecated + function handleOnKeyDown(evt) { + evt.persist(); + if (matches(evt, [keys.Enter, keys.Space])) { + evt.preventDefault(); + setIsSelected(!isSelected); + onChange(evt); + } + keyDownHandler(evt); + } + + function handleChange(event) { + setIsSelected(event.target.checked); + onChange(event); + } + + if (selected !== prevSelected) { + setIsSelected(selected); + setPrevSelected(selected); + } + + return ( + <> + + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + + + ); +}); + +SelectableTile.displayName = 'SelectableTile'; +SelectableTile.propTypes = { + /** + * The child nodes. + */ + children: PropTypes.node, + + /** + * The CSS class names. + */ + className: PropTypes.string, + + /** + * Specify whether the SelectableTile should be disabled + */ + disabled: PropTypes.bool, + + /** + * Specify the function to run when the SelectableTile is clicked + */ + handleClick: deprecate( + PropTypes.func, + 'The `handleClick` prop for `SelectableTile` has been deprecated in favor of `onClick`. It will be removed in the next major release.' + ), + + /** + * Specify the function to run when the SelectableTile is interacted with via a keyboard + */ + handleKeyDown: deprecate( + PropTypes.func, + 'The `handleKeyDown` prop for `SelectableTile` has been deprecated in favor of `onKeyDown`. It will be removed in the next major release.' + ), + + /** + * The description of the checkmark icon. + */ + iconDescription: deprecate( + PropTypes.string, + 'The `iconDescription` prop for `SelectableTile` is no longer needed and has ' + + 'been deprecated. It will be removed in the next major release.' + ), + + /** + * The ID of the ``. + */ + id: PropTypes.string, + + /** + * `true` to use the light version. For use on $ui-01 backgrounds only. + * Don't use this to make tile background color same as container background color. + */ + light: deprecate( + PropTypes.bool, + 'The `light` prop for `Tile` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead.' + ), + + /** + * The `name` of the ``. + */ + name: PropTypes.string, + + /** + * The empty handler of the ``. + */ + onChange: PropTypes.func, + + /** + * Specify the function to run when the SelectableTile is clicked + */ + onClick: PropTypes.func, + + /** + * Specify the function to run when the SelectableTile is interacted with via a keyboard + */ + onKeyDown: PropTypes.func, + + /** + * `true` to select this tile. + */ + selected: PropTypes.bool, + + /** + * Specify the tab index of the wrapper element + */ + tabIndex: PropTypes.number, + + /** + * The `title` of the ``. + */ + title: PropTypes.string, + + /** + * The value of the ``. + */ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, +}; + +export class ExpandableTile extends Component { + state = {}; + + static propTypes = { + /** + * The child nodes. + */ + children: PropTypes.node, + + /** + * The CSS class names. + */ + className: PropTypes.string, + + /** + * `true` if the tile is expanded. + */ + expanded: PropTypes.bool, + + /** + * Deprecated in v11. Use 'onClick' instead. + */ + handleClick: deprecate( + PropTypes.func, + 'The handleClick prop for ClickableTile has been deprecated in favor of onClick. It will be removed in the next major release.' + ), + + /** + * An ID that can be provided to aria-labelledby + */ + id: PropTypes.string, + + /** + * `true` to use the light version. For use on $ui-01 backgrounds only. + * Don't use this to make tile background color same as container background color. + */ + light: deprecate( + PropTypes.bool, + 'The `light` prop for `Tile` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead.' + ), + + /** + * optional handler to decide whether to ignore a click. returns false if click should be ignored + */ + onBeforeClick: PropTypes.func, + + /** + * Specify the function to run when the ExpandableTile is clicked + + */ + onClick: PropTypes.func, + + /** + * optional handler to trigger a function when a key is pressed + */ + onKeyUp: PropTypes.func, + + /** + * The `tabindex` attribute. + */ + tabIndex: PropTypes.number, + + /** + * The description of the "collapsed" icon that can be read by screen readers. + */ + tileCollapsedIconText: PropTypes.string, + + /** + * When "collapsed", a label to appear next to the chevron (e.g., "View more"). + */ + tileCollapsedLabel: PropTypes.string, + + /** + * The description of the "expanded" icon that can be read by screen readers. + */ + tileExpandedIconText: PropTypes.string, + + /** + * When "expanded", a label to appear next to the chevron (e.g., "View less"). + */ + tileExpandedLabel: PropTypes.string, + }; + + static defaultProps = { + tabIndex: 0, + expanded: false, + tileMaxHeight: 0, + tilePadding: 0, + onBeforeClick: () => true, + onClick: () => {}, + tileCollapsedIconText: 'Interact to expand Tile', + tileExpandedIconText: 'Interact to collapse Tile', + light: false, + }; + + static contextType = PrefixContext; + + static getDerivedStateFromProps( + // eslint-disable-next-line react/prop-types + { expanded, tileMaxHeight, tilePadding }, + state + ) { + const { + prevExpanded, + prevTileMaxHeight, + prevTilePadding, + expanded: currentExpanded, + tileMaxHeight: currentTileMaxHeight, + tilePadding: currentTilePadding, + } = state; + const expandedChanged = prevExpanded !== expanded; + const tileMaxHeightChanged = prevTileMaxHeight !== tileMaxHeight; + const tilePaddingChanged = prevTilePadding !== tilePadding; + return !expandedChanged && !tileMaxHeightChanged && !tilePaddingChanged + ? null + : { + expanded: !expandedChanged ? currentExpanded : expanded, + tileMaxHeight: !tileMaxHeightChanged + ? currentTileMaxHeight + : tileMaxHeight, + tilePadding: !tilePaddingChanged ? currentTilePadding : tilePadding, + prevExpanded: expanded, + prevTileMaxHeight: tileMaxHeight, + prevTilePadding: tilePadding, + }; + } + + componentDidMount = () => { + if (this.tile) { + const getStyle = window.getComputedStyle(this.tile, null); + + if (this.aboveTheFold) { + this.setState({ + tileMaxHeight: this.aboveTheFold.getBoundingClientRect().height, + tilePadding: + parseInt(getStyle.getPropertyValue('padding-top'), 10) + + parseInt(getStyle.getPropertyValue('padding-bottom'), 10), + }); + } + } + }; + + componentDidUpdate = (prevProps) => { + if (prevProps.expanded !== this.props.expanded) { + this.setMaxHeight(); + } + }; + + setMaxHeight = () => { + if (this.state.expanded ? this.tileContent : this.aboveTheFold) { + this.setState({ + tileMaxHeight: this.state.expanded + ? this.tileContent.getBoundingClientRect().height + : this.aboveTheFold.getBoundingClientRect().height, + }); + } + }; + + handleClick = (evt) => { + if (!this.props.onBeforeClick(evt) || evt.target.tagName === 'INPUT') { + return; + } + evt.persist(); + this.setState( + { + expanded: !this.state.expanded, + }, + () => { + this.setMaxHeight(); + // TODO: Remove handleClick prop when handleClick is deprecated + this.props.handleClick?.(evt) || this.props.onClick?.(evt); + } + ); + }; + + handleKeyUp = (evt) => { + if (evt.target !== this.tile) { + if (matches(evt, [keys.Enter, keys.Space])) { + evt.preventDefault(); + } + } + }; + + getChildren = () => { + return React.Children.toArray(this.props.children); + }; + + render() { + const { + tabIndex, + className, + expanded, // eslint-disable-line + tileMaxHeight, // eslint-disable-line + tilePadding, // eslint-disable-line + handleClick, // eslint-disable-line + onClick, + onKeyUp, + tileCollapsedIconText, + tileExpandedIconText, + tileCollapsedLabel, + tileExpandedLabel, + onBeforeClick, // eslint-disable-line + light, + ...rest + } = this.props; + + const prefix = this.context; + + const { expanded: isExpanded } = this.state; + + const classes = cx( + `${prefix}--tile`, + `${prefix}--tile--expandable`, + { + [`${prefix}--tile--is-expanded`]: isExpanded, + [`${prefix}--tile--light`]: light, + }, + className + ); + + const tileStyle = { + maxHeight: isExpanded + ? null + : this.state.tileMaxHeight + this.state.tilePadding, + }; + + const childrenAsArray = this.getChildren(); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions + + ); + } +} + +export const TileAboveTheFoldContent = React.forwardRef( + function TileAboveTheFoldContent({ children }, ref) { + const prefix = usePrefix(); + + return ( + + {children} + + ); + } +); + +TileAboveTheFoldContent.displayName = 'TileAboveTheFoldContent'; +TileAboveTheFoldContent.propTypes = { + /** + * The child nodes. + */ + children: PropTypes.node, +}; + +export const TileBelowTheFoldContent = React.forwardRef( + function TileBelowTheFoldContent({ children }, ref) { + const prefix = usePrefix(); + + return ( + + {children} + + ); + } +); + +TileBelowTheFoldContent.displayName = 'TileBelowTheFoldContent'; +TileBelowTheFoldContent.propTypes = { + /** + * The child nodes. + */ + children: PropTypes.node, +};