From b70bf79aaf467f33030ec26146c08d858d310128 Mon Sep 17 00:00:00 2001 From: Alessandra Davila Date: Tue, 12 Jul 2022 10:13:18 -0500 Subject: [PATCH 01/16] docs(ui-shell): update docs to remove the undefined tab (#11743) * docs(ui-shell): update docs to remove the undefined tab * chore(ui-shell): remove args * feat(ui-shell): remove default props sidenav link and export header menu --- .../__tests__/__snapshots__/PublicAPI-test.js.snap | 4 ---- packages/react/src/components/UIShell/HeaderMenu.js | 2 ++ .../react/src/components/UIShell/HeaderMenuButton.js | 3 +++ packages/react/src/components/UIShell/SideNavLink.js | 9 ++------- .../react/src/components/UIShell/UIShell.stories.js | 12 ++++++++++-- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index e67176bca615..176a3a2734ef 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -6009,10 +6009,6 @@ Map { }, "SideNavLink" => Object { "$$typeof": Symbol(react.forward_ref), - "defaultProps": Object { - "element": "a", - "large": false, - }, "propTypes": Object { "children": Object { "isRequired": true, diff --git a/packages/react/src/components/UIShell/HeaderMenu.js b/packages/react/src/components/UIShell/HeaderMenu.js index eda66ff10d48..6a9edb49dba9 100644 --- a/packages/react/src/components/UIShell/HeaderMenu.js +++ b/packages/react/src/components/UIShell/HeaderMenu.js @@ -241,4 +241,6 @@ const HeaderMenuForwardRef = React.forwardRef((props, ref) => { }); HeaderMenuForwardRef.displayName = 'HeaderMenu'; + +export { HeaderMenu }; export default HeaderMenuForwardRef; diff --git a/packages/react/src/components/UIShell/HeaderMenuButton.js b/packages/react/src/components/UIShell/HeaderMenuButton.js index 0da4aaedec38..a7a910500d0b 100644 --- a/packages/react/src/components/UIShell/HeaderMenuButton.js +++ b/packages/react/src/components/UIShell/HeaderMenuButton.js @@ -65,6 +65,9 @@ HeaderMenuButton.propTypes = { */ className: PropTypes.string, + /** + * Specify whether the menu button is "active". + */ isActive: PropTypes.bool, /** diff --git a/packages/react/src/components/UIShell/SideNavLink.js b/packages/react/src/components/UIShell/SideNavLink.js index bc6c729f9748..ff8fb4772e9c 100644 --- a/packages/react/src/components/UIShell/SideNavLink.js +++ b/packages/react/src/components/UIShell/SideNavLink.js @@ -16,11 +16,11 @@ import { usePrefix } from '../../internal/usePrefix'; const SideNavLink = React.forwardRef(function SideNavLink( { - className: customClassName, children, + className: customClassName, renderIcon: IconElement, isActive, - large, + large = false, ...rest }, ref @@ -71,11 +71,6 @@ SideNavLink.propTypes = { renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), }; -SideNavLink.defaultProps = { - element: 'a', - large: false, -}; - // eslint-disable-next-line react/display-name export const createCustomSideNavLink = (element) => (props) => { return ; diff --git a/packages/react/src/components/UIShell/UIShell.stories.js b/packages/react/src/components/UIShell/UIShell.stories.js index 0fad7864e155..1dda7bf0a942 100644 --- a/packages/react/src/components/UIShell/UIShell.stories.js +++ b/packages/react/src/components/UIShell/UIShell.stories.js @@ -32,6 +32,7 @@ import { SwitcherItem, SwitcherDivider, } from './'; +import { HeaderMenu as HeaderMenuNative } from './HeaderMenu'; import Modal from '../Modal'; import Button from '../Button'; import { @@ -132,13 +133,13 @@ const StoryContent = ({ useResponsiveOffset = true }) => { // eslint-disable-next-line storybook/csf-component export default { title: 'Components/UI Shell', + component: Header, subcomponents: { Content, - Header, HeaderMenuButton, HeaderName, HeaderNavigation, - HeaderMenu, + HeaderMenu: HeaderMenuNative, HeaderMenuItem, HeaderGlobalBar, HeaderGlobalAction, @@ -160,6 +161,13 @@ export default { page: mdx, }, }, + argTypes: { + className: { + table: { + disable: true, + }, + }, + }, }; export const HeaderBase = () => ( From dbb82686fd6ba82eb1debc7d250246a6a242f54f Mon Sep 17 00:00:00 2001 From: TJ Egan Date: Wed, 13 Jul 2022 10:23:04 -0400 Subject: [PATCH 02/16] fix(Loading): improve screenreader support (#11759) * fix(Loading): improve screenreader support * fix(Loading): more screenreader tweaks * test(snapshot): update snapshots * test(Loading): update Loading tests * test(format): format files --- .../__snapshots__/PublicAPI-test.js.snap | 6 ++---- .../InlineLoading/InlineLoading-story.js | 5 +---- .../InlineLoading/InlineLoading-test.js | 8 ++------ .../components/InlineLoading/InlineLoading.js | 10 +++++++--- .../InlineLoading/next/InlineLoading.stories.js | 2 +- .../src/components/Loading/Loading-story.js | 12 +----------- .../src/components/Loading/Loading-test.js | 7 ------- .../react/src/components/Loading/Loading.js | 17 ++++------------- 8 files changed, 18 insertions(+), 49 deletions(-) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 176a3a2734ef..84b9c05b8b4b 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -3793,7 +3793,7 @@ Map { "Loading" => Object { "defaultProps": Object { "active": true, - "description": "Active loading indicator", + "description": "loading", "small": false, "withOverlay": true, }, @@ -3807,9 +3807,7 @@ Map { "description": Object { "type": "string", }, - "id": Object { - "type": "string", - }, + "id": [Function], "small": Object { "type": "bool", }, diff --git a/packages/react/src/components/InlineLoading/InlineLoading-story.js b/packages/react/src/components/InlineLoading/InlineLoading-story.js index db876a6b4f16..5a41c7c776e0 100644 --- a/packages/react/src/components/InlineLoading/InlineLoading-story.js +++ b/packages/react/src/components/InlineLoading/InlineLoading-story.js @@ -18,10 +18,7 @@ const props = () => ({ ['inactive', 'active', 'finished', 'error'], 'active' ), - iconDescription: text( - 'Icon description (iconDescription)', - 'Active loading indicator' - ), + iconDescription: text('Icon description (iconDescription)', 'loading'), description: text( 'Loading progress description (description)', 'Loading data...' diff --git a/packages/react/src/components/InlineLoading/InlineLoading-test.js b/packages/react/src/components/InlineLoading/InlineLoading-test.js index 00c0954db3e9..5007d6cc77e6 100644 --- a/packages/react/src/components/InlineLoading/InlineLoading-test.js +++ b/packages/react/src/components/InlineLoading/InlineLoading-test.js @@ -19,17 +19,13 @@ describe('InlineLoading', () => { it('should render a loader by default', () => { render(); - expect( - screen.getByLabelText('Active loading indicator') - ).toBeInTheDocument(); + expect(screen.getByTitle('loading')).toBeInTheDocument(); }); it('should render a loader if the status is inactive', () => { render(); - expect( - screen.getByLabelText('Active loading indicator') - ).toBeInTheDocument(); + expect(screen.getByTitle('not loading')).toBeInTheDocument(); }); it('should render the success state if status is finished', () => { diff --git a/packages/react/src/components/InlineLoading/InlineLoading.js b/packages/react/src/components/InlineLoading/InlineLoading.js index 0881e8df4685..0b0724fa3c05 100644 --- a/packages/react/src/components/InlineLoading/InlineLoading.js +++ b/packages/react/src/components/InlineLoading/InlineLoading.js @@ -24,10 +24,11 @@ export default function InlineLoading({ const prefix = usePrefix(); const loadingClasses = classNames(`${prefix}--inline-loading`, className); const getLoading = () => { + let iconLabel = iconDescription ? iconDescription : status; if (status === 'error') { return ( - {iconDescription} + {iconLabel} ); } @@ -40,15 +41,18 @@ export default function InlineLoading({ return ( - {iconDescription} + {iconLabel} ); } if (status === 'inactive' || status === 'active') { + if (!iconDescription) { + iconLabel = status === 'active' ? 'loading' : 'not loading'; + } return ( diff --git a/packages/react/src/components/InlineLoading/next/InlineLoading.stories.js b/packages/react/src/components/InlineLoading/next/InlineLoading.stories.js index 252d1c63f53f..7f4ca6da067d 100644 --- a/packages/react/src/components/InlineLoading/next/InlineLoading.stories.js +++ b/packages/react/src/components/InlineLoading/next/InlineLoading.stories.js @@ -17,7 +17,7 @@ export default { export const _InlineLoading = () => ( ); diff --git a/packages/react/src/components/Loading/Loading-story.js b/packages/react/src/components/Loading/Loading-story.js index e2a6bd4205c5..5aba97dd12f7 100644 --- a/packages/react/src/components/Loading/Loading-story.js +++ b/packages/react/src/components/Loading/Loading-story.js @@ -13,7 +13,7 @@ const props = () => ({ active: boolean('Active (active)', true), withOverlay: boolean('With overlay (withOverlay)', false), small: boolean('Small (small)', false), - description: text('Description (description)', 'Active loading indicator'), + description: text('Description (description)', 'Loading'), }); export default { @@ -25,13 +25,3 @@ export default { export const Default = () => { return ; }; - -Default.parameters = { - info: { - text: ` - Loading spinners are used when retrieving data or performing slow computations, - and help to notify users that loading is underway. The 'active' property is true by default; - set to false to end the animation. - `, - }, -}; diff --git a/packages/react/src/components/Loading/Loading-test.js b/packages/react/src/components/Loading/Loading-test.js index b3dfbc2b4bb2..dae90cc00637 100644 --- a/packages/react/src/components/Loading/Loading-test.js +++ b/packages/react/src/components/Loading/Loading-test.js @@ -36,13 +36,6 @@ describe('Loading', () => { const { container } = render(); const liveRegion = container.querySelector('[aria-live]'); expect(liveRegion).toBeInstanceOf(HTMLElement); - - const id = liveRegion.getAttribute('aria-labelledby'); - expect(id).toBeDefined(); - - const label = document.getElementById(id); - expect(label).toBeDefined(); - expect(typeof label.textContent).toBe('string'); }); // https://www.w3.org/TR/WCAG21/#status-messages diff --git a/packages/react/src/components/Loading/Loading.js b/packages/react/src/components/Loading/Loading.js index 460fded0e183..d8a3f8475c5f 100644 --- a/packages/react/src/components/Loading/Loading.js +++ b/packages/react/src/components/Loading/Loading.js @@ -7,14 +7,11 @@ import cx from 'classnames'; import PropTypes from 'prop-types'; -import React, { useRef } from 'react'; -import setupGetInstanceId from '../../tools/setupGetInstanceId'; +import React from 'react'; import { usePrefix } from '../../internal/usePrefix'; - -const getInstanceId = setupGetInstanceId(); +import deprecate from '../../prop-types/deprecate'; function Loading({ - id, active, className: customClassName, withOverlay, @@ -23,7 +20,6 @@ function Loading({ ...rest }) { const prefix = usePrefix(); - const { current: instanceId } = useRef(getInstanceId()); const loadingClassName = cx(customClassName, { [`${prefix}--loading`]: true, [`${prefix}--loading--small`]: small, @@ -33,18 +29,13 @@ function Loading({ [`${prefix}--loading-overlay`]: true, [`${prefix}--loading-overlay--stop`]: !active, }); - const loadingId = id || `loading-id-${instanceId}`; const loading = (
- {description} {small ? ( @@ -91,7 +82,7 @@ Loading.propTypes = { /** * Provide an `id` to uniquely identify the label */ - id: PropTypes.string, + id: deprecate(PropTypes.string, `\nThe prop \`id\` is no longer needed.`), /** * Specify whether you would like the small variant of @@ -108,7 +99,7 @@ Loading.defaultProps = { active: true, withOverlay: true, small: false, - description: 'Active loading indicator', + description: 'loading', }; export default Loading; From a3e74c3635900db6a91de34d729f09ae43f2b8f2 Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Wed, 13 Jul 2022 11:52:29 -0500 Subject: [PATCH 03/16] fix(switch): allow children to render (#11791) * fix(switch): allow children to render * test(api): update public api snapshot --- packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap | 1 - .../src/components/ContentSwitcher/ContentSwitcher.stories.js | 2 +- packages/react/src/components/Switch/Switch.js | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 84b9c05b8b4b..f983c2520855 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -6310,7 +6310,6 @@ Map { "onClick": [Function], "onKeyDown": [Function], "selected": false, - "text": "Provide text", }, "propTypes": Object { "children": Object { diff --git a/packages/react/src/components/ContentSwitcher/ContentSwitcher.stories.js b/packages/react/src/components/ContentSwitcher/ContentSwitcher.stories.js index e7f922c634e3..61e554d893c8 100644 --- a/packages/react/src/components/ContentSwitcher/ContentSwitcher.stories.js +++ b/packages/react/src/components/ContentSwitcher/ContentSwitcher.stories.js @@ -34,7 +34,7 @@ export default { export const Default = () => ( {}}> - + Second section ); diff --git a/packages/react/src/components/Switch/Switch.js b/packages/react/src/components/Switch/Switch.js index d2ba2d2236b1..4fc4d7e93ea6 100644 --- a/packages/react/src/components/Switch/Switch.js +++ b/packages/react/src/components/Switch/Switch.js @@ -117,7 +117,6 @@ Switch.propTypes = { Switch.defaultProps = { selected: false, - text: 'Provide text', onClick: () => {}, onKeyDown: () => {}, }; From 646672fc9f75eaba2725cf2b1233fa9f5ae1c0ad Mon Sep 17 00:00:00 2001 From: TJ Egan Date: Wed, 13 Jul 2022 13:50:06 -0400 Subject: [PATCH 04/16] fix(DataTable): prevent overlap with sortable selection table (#11758) Co-authored-by: D.A. Kahn <40970507+dakahn@users.noreply.github.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- packages/styles/scss/components/data-table/_data-table.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styles/scss/components/data-table/_data-table.scss b/packages/styles/scss/components/data-table/_data-table.scss index dc65169b4f22..4e8854ec1dc9 100644 --- a/packages/styles/scss/components/data-table/_data-table.scss +++ b/packages/styles/scss/components/data-table/_data-table.scss @@ -348,7 +348,7 @@ .#{$prefix}--data-table thead th.#{$prefix}--table-column-checkbox, .#{$prefix}--data-table tbody td.#{$prefix}--table-column-checkbox { - width: 2.5rem; + min-width: 2.5rem; // spacing between checkbox / chevron and next cell should be 8px / 0.5rem padding-right: rem(4px); padding-left: 1rem; From 2263ace623226fe210a063da6745d14ccdbb3d86 Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Wed, 13 Jul 2022 16:56:24 -0500 Subject: [PATCH 05/16] fix(react): remove old FilterableMultiselect, stories, and tests (#11778) * chore(react): check in progress * feat(react): remove old tests and components Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../__snapshots__/PublicAPI-test.js.snap | 6 +- .../MultiSelect/FilterableMultiSelect.js | 1126 ++++++++--------- .../MultiSelect/MultiSelect-story.js | 350 ----- .../{next => }/MultiSelect.stories.js | 4 +- .../FilterableMultiSelect-test.e2e.js | 4 +- .../__tests__/FilterableMultiSelect-test.js | 4 +- .../FilterableMultiSelect-test.js.snap | 4 +- .../react/src/components/MultiSelect/index.js | 13 +- .../MultiSelect/next/FilterableMultiSelect.js | 600 --------- .../FilterableMultiSelect-test.e2e.js | 136 -- .../__tests__/FilterableMultiSelect-test.js | 166 --- .../FilterableMultiSelect-test.js.snap | 187 --- 12 files changed, 556 insertions(+), 2044 deletions(-) delete mode 100644 packages/react/src/components/MultiSelect/MultiSelect-story.js rename packages/react/src/components/MultiSelect/{next => }/MultiSelect.stories.js (98%) delete mode 100644 packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js delete mode 100644 packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.e2e.js delete mode 100644 packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.js delete mode 100644 packages/react/src/components/MultiSelect/next/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index f983c2520855..c7c625f7aec7 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -4157,9 +4157,7 @@ Map { "MultiSelect" => Object { "$$typeof": Symbol(react.forward_ref), "Filterable": Object { - "contextType": Object { - "$$typeof": Symbol(react.context), - }, + "$$typeof": Symbol(react.forward_ref), "defaultProps": Object { "ariaLabel": "Choose an item", "compareItems": [Function], @@ -4362,7 +4360,6 @@ Map { "type": "bool", }, "placeholder": Object { - "isRequired": true, "type": "string", }, "selectionFeedback": Object { @@ -4402,6 +4399,7 @@ Map { "type": "node", }, }, + "render": [Function], }, "defaultProps": Object { "clearSelectionDescription": "Total items selected: ", diff --git a/packages/react/src/components/MultiSelect/FilterableMultiSelect.js b/packages/react/src/components/MultiSelect/FilterableMultiSelect.js index 1bb38db8d002..4454f3ab139a 100644 --- a/packages/react/src/components/MultiSelect/FilterableMultiSelect.js +++ b/packages/react/src/components/MultiSelect/FilterableMultiSelect.js @@ -10,7 +10,7 @@ import cx from 'classnames'; import Downshift from 'downshift'; import isEqual from 'lodash.isequal'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState, useRef } from 'react'; import { defaultFilterItems } from '../ComboBox/tools/filter'; import { sortingPropTypes } from './MultiSelectPropTypes'; import ListBox, { PropTypes as ListBoxPropTypes } from '../ListBox'; @@ -19,221 +19,127 @@ import { match, keys } from '../../internal/keyboard'; import Selection from '../../internal/Selection'; import { defaultItemToString } from './tools/itemToString'; import mergeRefs from '../../tools/mergeRefs'; -import setupGetInstanceId from '../../tools/setupGetInstanceId'; +import { useId } from '../../internal/useId'; import { defaultSortItems, defaultCompareItems } from './tools/sorting'; -import { FeatureFlagContext } from '../FeatureFlags'; -import { PrefixContext } from '../../internal/usePrefix'; - -const getInstanceId = setupGetInstanceId(); - -export default class FilterableMultiSelect extends React.Component { - static propTypes = { - /** - * 'aria-label' of the ListBox component. - */ - ariaLabel: PropTypes.string, - - /** - * Specify the direction of the multiselect dropdown. Can be either top or bottom. - */ - direction: PropTypes.oneOf(['top', 'bottom']), - - /** - * Disable the control - */ - disabled: PropTypes.bool, - - /** - * Additional props passed to Downshift - */ - downshiftProps: PropTypes.shape(Downshift.propTypes), - - /** - * Specify whether the title text should be hidden or not - */ - hideLabel: PropTypes.bool, - - /** - * Specify a custom `id` - */ - id: PropTypes.string.isRequired, - - /** - * Allow users to pass in arbitrary items from their collection that are - * pre-selected - */ - initialSelectedItems: PropTypes.array, - - /** - * Is the current selection invalid? - */ - invalid: PropTypes.bool, - - /** - * If invalid, what is the error? - */ - invalidText: PropTypes.node, - - /** - * Function to render items as custom components instead of strings. - * Defaults to null and is overridden by a getter - */ - itemToElement: PropTypes.func, - - /** - * Helper function passed to downshift that allows the library to render a - * given item to a string label. By default, it extracts the `label` field - * from a given item to serve as the item label in the list. - */ - itemToString: PropTypes.func, - - /** - * We try to stay as generic as possible here to allow individuals to pass - * in a collection of whatever kind of data structure they prefer - */ - items: PropTypes.array.isRequired, - - /** - * `true` to use the light version. - */ - light: PropTypes.bool, - - /** - * Specify the locale of the control. Used for the default `compareItems` - * used for sorting the list of items in the control. - */ - locale: PropTypes.string, - - /** - * `onChange` is a utility for this controlled component to communicate to a - * consuming component what kind of internal state changes are occurring. - */ - onChange: PropTypes.func, - - /** - * `onInputValueChange` is a utility for this controlled component to communicate to - * the currently typed input. - */ - onInputValueChange: PropTypes.func, - - /** - * `onMenuChange` is a utility for this controlled component to communicate to a - * consuming component that the menu was opened(`true`)/closed(`false`). - */ - onMenuChange: PropTypes.func, - - /** - * Initialize the component with an open(`true`)/closed(`false`) menu. - */ - open: PropTypes.bool, - - /** - * Generic `placeholder` that will be used as the textual representation of - * what this field is for - */ - placeholder: PropTypes.string.isRequired, - - /** - * Specify feedback (mode) of the selection. - * `top`: selected item jumps to top - * `fixed`: selected item stays at it's position - * `top-after-reopen`: selected item jump to top after reopen dropdown - */ - selectionFeedback: PropTypes.oneOf(['top', 'fixed', 'top-after-reopen']), - - /** - * Specify the size of the ListBox. Currently supports either `sm`, `md` or `lg` as an option. - */ - size: ListBoxPropTypes.ListBoxSize, - - ...sortingPropTypes, - - /** - * Callback function for translating ListBoxMenuIcon SVG title - */ - translateWithId: PropTypes.func, - - /** - * Specify title to show title on hover - */ - useTitleInItem: PropTypes.bool, - - /** - * Specify whether the control is currently in warning state - */ - warn: PropTypes.bool, - - /** - * Provide the text that is displayed when the control is in warning state - */ - warnText: PropTypes.node, - }; - - static contextType = FeatureFlagContext; - - static getDerivedStateFromProps({ open }, state) { - /** - * programmatically control this `open` prop - */ - const { prevOpen } = state; - return prevOpen === open - ? null - : { - isOpen: open, - prevOpen: open, - }; +import { useFeatureFlag } from '../FeatureFlags'; +import { usePrefix } from '../../internal/usePrefix'; + +const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect( + { + ariaLabel, + className: containerClassName, + compareItems, + direction, + disabled, + downshiftProps, + filterItems, + helperText, + hideLabel, + id, + initialSelectedItems, + invalid, + invalidText, + items, + itemToElement: ItemToElement, // needs to be capitalized for react to render it correctly + itemToString, + light, + locale, + onInputValueChange, + open, + onChange, + onMenuChange, + placeholder, + titleText, + type, + selectionFeedback, + size, + sortItems, + translateWithId, + useTitleInItem, + warn, + warnText, + }, + ref +) { + const [isOpen, setIsOpen] = useState(open); + const [prevOpen, setPrevOpen] = useState(open); + const [inputValue, setInputValue] = useState(''); + const [topItems, setTopItems] = useState([]); + const [inputFocused, setInputFocused] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(null); + const textInput = useRef(); + const filterableMultiSelectInstanceId = useId(); + + const enabled = useFeatureFlag('enable-v11-release'); + const prefix = usePrefix(); + + if (prevOpen !== open) { + setIsOpen(open); + setPrevOpen(open); } - static defaultProps = { - ariaLabel: 'Choose an item', - compareItems: defaultCompareItems, - direction: 'bottom', - disabled: false, - filterItems: defaultFilterItems, - initialSelectedItems: [], - itemToString: defaultItemToString, - locale: 'en', - sortItems: defaultSortItems, - light: false, - open: false, - selectionFeedback: 'top-after-reopen', - }; - - constructor(props) { - super(props); - this.filterableMultiSelectInstanceId = getInstanceId(); - this.state = { - isOpen: props.open, - inputValue: '', - topItems: [], - inputFocused: false, - highlightedIndex: null, - }; - this.textInput = React.createRef(); + const inline = type === 'inline'; + const showWarning = !invalid && warn; + + const wrapperClasses = cx( + `${prefix}--multi-select__wrapper`, + `${prefix}--list-box__wrapper`, + [enabled ? containerClassName : null], + { + [`${prefix}--multi-select__wrapper--inline`]: inline, + [`${prefix}--list-box__wrapper--inline`]: inline, + [`${prefix}--multi-select__wrapper--inline--invalid`]: inline && invalid, + [`${prefix}--list-box__wrapper--inline--invalid`]: inline && invalid, + [`${prefix}--list-box--up`]: direction === 'top', + } + ); + const helperId = !helperText + ? undefined + : `filterablemultiselect-helper-text-${filterableMultiSelectInstanceId}`; + const labelId = `${id}-label`; + const titleClasses = cx({ + [`${prefix}--label`]: true, + [`${prefix}--label--disabled`]: disabled, + [`${prefix}--visually-hidden`]: hideLabel, + }); + const helperClasses = cx({ + [`${prefix}--form__helper-text`]: true, + [`${prefix}--form__helper-text--disabled`]: disabled, + }); + const inputClasses = cx({ + [`${prefix}--text-input`]: true, + [`${prefix}--text-input--empty`]: !inputValue, + [`${prefix}--text-input--light`]: light, + }); + const helper = helperText ? ( +
+ {helperText} +
+ ) : null; + const menuId = `${id}__menu`; + const inputId = `${id}-input`; + + function handleOnChange(changes) { + if (onChange) { + onChange(changes); + } } - handleOnChange = (changes) => { - if (this.props.onChange) { - this.props.onChange(changes); + function handleOnMenuChange(forceIsOpen) { + const nextIsOpen = forceIsOpen ?? !isOpen; + setIsOpen(nextIsOpen); + if (onMenuChange) { + onMenuChange(nextIsOpen); } - }; - - handleOnMenuChange = (isOpen) => { - this.setState((state) => ({ - isOpen: isOpen ?? !state.isOpen, - })); - if (this.props.onMenuChange) { - this.props.onMenuChange(isOpen); - } - }; + } - handleOnOuterClick = () => { - this.handleOnMenuChange(false); - }; + function handleOnOuterClick() { + handleOnMenuChange(false); + } - handleOnStateChange = (changes, downshift) => { - if (changes.isOpen && !this.state.isOpen) { - this.setState({ topItems: downshift.selectedItem }); + function handleOnStateChange(changes, downshift) { + if (changes.isOpen && !isOpen) { + setTopItems(downshift.selectedItem); } const { type } = changes; @@ -244,397 +150,451 @@ export default class FilterableMultiSelect extends React.Component { case stateChangeTypes.keyDownArrowUp: case stateChangeTypes.keyDownHome: case stateChangeTypes.keyDownEnd: - this.setState({ - highlightedIndex: - changes.highlightedIndex !== undefined - ? changes.highlightedIndex - : null, - }); - if (stateChangeTypes.keyDownArrowDown === type && !this.state.isOpen) { - this.handleOnMenuChange(true); + setHighlightedIndex( + changes.highlightedIndex !== undefined + ? changes.highlightedIndex + : null + ); + if (stateChangeTypes.keyDownArrowDown === type && !isOpen) { + handleOnMenuChange(true); } break; case stateChangeTypes.keyDownEscape: - this.handleOnMenuChange(false); + handleOnMenuChange(false); break; } - }; - - handleOnInputKeyDown = (event) => { - event.stopPropagation(); - }; + } - handleOnInputValueChange = (inputValue, { type }) => { - if (this.props.onInputValueChange) { - this.props.onInputValueChange(inputValue); + function handleOnInputValueChange(inputValue, { type }) { + if (onInputValueChange) { + onInputValueChange(inputValue); } if (type !== Downshift.stateChangeTypes.changeInput) { return; } - this.setState(() => { - if (Array.isArray(inputValue)) { - return { - inputValue: '', - }; - } - return { - inputValue: inputValue || '', - }; - }); - - if (inputValue && !this.state.isOpen) { - this.handleOnMenuChange(true); - } else if (!inputValue && this.state.isOpen) { - this.handleOnMenuChange(false); + if (Array.isArray(inputValue)) { + clearInputValue(); + } else { + setInputValue(inputValue); } - }; - - clearInputValue = () => { - this.setState({ inputValue: '' }, () => { - if (this.textInput.current) { - this.textInput.current.focus(); - } - }); - }; - - render() { - const { highlightedIndex, isOpen, inputValue } = this.state; - const { - ariaLabel, - className: containerClassName, - direction, - disabled, - filterItems, - items, - itemToElement, - itemToString, - titleText, - hideLabel, - helperText, - type, - initialSelectedItems, - id, - locale, - size, - placeholder, - sortItems, - compareItems, - light, - invalid, - invalidText, - warn, - warnText, - useTitleInItem, - translateWithId, - downshiftProps, - } = this.props; - const inline = type === 'inline'; - const showWarning = !invalid && warn; - - // needs to be capitalized for react to render it correctly - const ItemToElement = itemToElement; - - const scope = this.context; - let enabled; - - if (scope.enabled) { - enabled = scope.enabled('enable-v11-release'); + + if (inputValue && !isOpen) { + handleOnMenuChange(true); + } else if (!inputValue && isOpen) { + handleOnMenuChange(false); } + } - return ( - - {(prefix) => { - const wrapperClasses = cx( - `${prefix}--multi-select__wrapper`, - `${prefix}--list-box__wrapper`, - [enabled ? containerClassName : null], - { - [`${prefix}--multi-select__wrapper--inline`]: inline, - [`${prefix}--list-box__wrapper--inline`]: inline, - [`${prefix}--multi-select__wrapper--inline--invalid`]: - inline && invalid, - [`${prefix}--list-box__wrapper--inline--invalid`]: - inline && invalid, - [`${prefix}--list-box--up`]: direction === 'top', + function clearInputValue() { + setInputValue(''); + if (textInput.current) { + textInput.current.focus(); + } + } + + return ( + ( + { + if (selectedItem !== null) { + onItemChange(selectedItem); } - ); - const helperId = !helperText - ? undefined - : `filterablemultiselect-helper-text-${this.filterableMultiSelectInstanceId}`; - const labelId = `${id}-label`; - const titleClasses = cx({ - [`${prefix}--label`]: true, - [`${prefix}--label--disabled`]: disabled, - [`${prefix}--visually-hidden`]: hideLabel, - }); - const helperClasses = cx({ - [`${prefix}--form__helper-text`]: true, - [`${prefix}--form__helper-text--disabled`]: disabled, - }); - const inputClasses = cx({ - [`${prefix}--text-input`]: true, - [`${prefix}--text-input--empty`]: !this.state.inputValue, - [`${prefix}--text-input--light`]: light, - }); - const helper = helperText ? ( -
- {helperText} -
- ) : null; - const menuId = `${id}__menu`; - const inputId = `${id}-input`; - - return ( - ( - + {({ + getInputProps, + getItemProps, + getLabelProps, + getMenuProps, + getRootProps, + getToggleButtonProps, + isOpen, + inputValue, + selectedItem, + }) => { + const className = cx( + `${prefix}--multi-select`, + `${prefix}--combo-box`, + `${prefix}--multi-select--filterable`, + [enabled ? null : containerClassName], + { + [`${prefix}--multi-select--invalid`]: invalid, + [`${prefix}--multi-select--open`]: isOpen, + [`${prefix}--multi-select--inline`]: inline, + [`${prefix}--multi-select--selected`]: selectedItem.length > 0, + [`${prefix}--multi-select--filterable--input-focused`]: + inputFocused, + } + ); + const rootProps = getRootProps( + {}, + { + suppressRefError: true, + } + ); + + const labelProps = getLabelProps(); + + const buttonProps = getToggleButtonProps({ + disabled, + onClick: () => { + handleOnMenuChange(!isOpen); + if (textInput.current) { + textInput.current.focus(); + } + }, + // When we moved the "root node" of Downshift to the for + // ARIA 1.2 compliance, we unfortunately hit this branch for the + // "mouseup" event that downshift listens to: + // https://github.com/downshift-js/downshift/blob/v5.2.1/src/downshift.js#L1051-L1065 + // + // As a result, it will reset the state of the component and so we + // stop the event from propagating to prevent this. This allows the + // toggleMenu behavior for the toggleButton to correctly open and + // close the menu. + onMouseUp(event) { + if (isOpen) { + event.stopPropagation(); + } + }, + }); + + const inputProps = getInputProps({ + 'aria-controls': isOpen ? menuId : null, + 'aria-describedby': helperText ? helperId : null, + // Remove excess aria `aria-labelledby`. HTML + )} + /> + ); +}); + +FilterableMultiSelect.propTypes = { + /** + * 'aria-label' of the ListBox component. + */ + ariaLabel: PropTypes.string, + + /** + * Specify the direction of the multiselect dropdown. Can be either top or bottom. + */ + direction: PropTypes.oneOf(['top', 'bottom']), + + /** + * Disable the control + */ + disabled: PropTypes.bool, + + /** + * Additional props passed to Downshift + */ + downshiftProps: PropTypes.shape(Downshift.propTypes), + + /** + * Specify whether the title text should be hidden or not + */ + hideLabel: PropTypes.bool, + + /** + * Specify a custom `id` + */ + id: PropTypes.string.isRequired, + + /** + * Allow users to pass in arbitrary items from their collection that are + * pre-selected + */ + initialSelectedItems: PropTypes.array, + + /** + * Is the current selection invalid? + */ + invalid: PropTypes.bool, + + /** + * If invalid, what is the error? + */ + invalidText: PropTypes.node, + + /** + * Function to render items as custom components instead of strings. + * Defaults to null and is overridden by a getter + */ + itemToElement: PropTypes.func, + + /** + * Helper function passed to downshift that allows the library to render a + * given item to a string label. By default, it extracts the `label` field + * from a given item to serve as the item label in the list. + */ + itemToString: PropTypes.func, + + /** + * We try to stay as generic as possible here to allow individuals to pass + * in a collection of whatever kind of data structure they prefer + */ + items: PropTypes.array.isRequired, + + /** + * `true` to use the light version. + */ + light: PropTypes.bool, + + /** + * Specify the locale of the control. Used for the default `compareItems` + * used for sorting the list of items in the control. + */ + locale: PropTypes.string, + + /** + * `onChange` is a utility for this controlled component to communicate to a + * consuming component what kind of internal state changes are occurring. + */ + onChange: PropTypes.func, + + /** + * `onInputValueChange` is a utility for this controlled component to communicate to + * the currently typed input. + */ + onInputValueChange: PropTypes.func, + + /** + * `onMenuChange` is a utility for this controlled component to communicate to a + * consuming component that the menu was opened(`true`)/closed(`false`). + */ + onMenuChange: PropTypes.func, + + /** + * Initialize the component with an open(`true`)/closed(`false`) menu. + */ + open: PropTypes.bool, + + /** + * Generic `placeholder` that will be used as the textual representation of + * what this field is for + */ + placeholder: PropTypes.string, + + /** + * Specify feedback (mode) of the selection. + * `top`: selected item jumps to top + * `fixed`: selected item stays at it's position + * `top-after-reopen`: selected item jump to top after reopen dropdown + */ + selectionFeedback: PropTypes.oneOf(['top', 'fixed', 'top-after-reopen']), + + /** + * Specify the size of the ListBox. Currently supports either `sm`, `md` or `lg` as an option. + */ + size: ListBoxPropTypes.ListBoxSize, + + ...sortingPropTypes, + + /** + * Callback function for translating ListBoxMenuIcon SVG title + */ + translateWithId: PropTypes.func, + + /** + * Specify title to show title on hover + */ + useTitleInItem: PropTypes.bool, + + /** + * Specify whether the control is currently in warning state + */ + warn: PropTypes.bool, + + /** + * Provide the text that is displayed when the control is in warning state + */ + warnText: PropTypes.node, +}; + +FilterableMultiSelect.defaultProps = { + ariaLabel: 'Choose an item', + compareItems: defaultCompareItems, + direction: 'bottom', + disabled: false, + filterItems: defaultFilterItems, + initialSelectedItems: [], + itemToString: defaultItemToString, + locale: 'en', + sortItems: defaultSortItems, + light: false, + open: false, + selectionFeedback: 'top-after-reopen', +}; + +export default FilterableMultiSelect; diff --git a/packages/react/src/components/MultiSelect/MultiSelect-story.js b/packages/react/src/components/MultiSelect/MultiSelect-story.js deleted file mode 100644 index e6a558005d32..000000000000 --- a/packages/react/src/components/MultiSelect/MultiSelect-story.js +++ /dev/null @@ -1,350 +0,0 @@ -/** - * 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, { useCallback, useState } from 'react'; -import { action } from '@storybook/addon-actions'; -import { - withKnobs, - boolean, - select, - text, - object, -} from '@storybook/addon-knobs'; -import { withReadme } from 'storybook-readme'; -import readme from './README.md'; -import MultiSelect from '../MultiSelect'; -import FilterableMultiSelect from '../MultiSelect/FilterableMultiSelect'; -import Checkbox from '../Checkbox'; -import mdx from './MultiSelect.mdx'; -import Button from '../Button'; - -const items = [ - { - id: 'downshift-1-item-0', - text: 'Option 1', - }, - { - id: 'downshift-1-item-1', - text: 'Option 2', - }, - { - id: 'downshift-1-item-2', - text: 'Option 3 - a disabled item', - disabled: true, - }, - { - id: 'downshift-1-item-3', - text: 'Option 4', - }, - { - id: 'downshift-1-item-4', - text: 'An example option that is really long to show what should be done to handle long text', - }, - { - id: 'downshift-1-item-5', - text: 'Option 5', - }, -]; - -const defaultLabel = 'MultiSelect Label'; -const defaultPlaceholder = 'Filter'; - -const types = { - 'Default (default)': 'default', - 'Inline (inline)': 'inline', -}; - -const sizes = { - 'Small (sm)': 'sm', - 'Medium (md) - default': undefined, - 'Large (lg)': 'lg', -}; - -const directions = { - 'Bottom (default)': 'bottom', - 'Top ': 'top', -}; - -const props = () => ({ - id: text('MultiSelect ID (id)', 'carbon-multiselect-example'), - titleText: text('Title (titleText)', 'Multiselect title'), - hideLabel: boolean('No title text shown (hideLabel)', false), - helperText: text('Helper text (helperText)', 'This is helper text'), - disabled: boolean('Disabled (disabled)', false), - light: boolean('Light variant (light)', false), - useTitleInItem: boolean('Show tooltip on hover', false), - type: select('UI type (Only for ``) (type)', types, 'default'), - size: select('Field size (size)', sizes, undefined) || undefined, - direction: select('Dropdown direction (direction)', directions, 'bottom'), - label: text('Label (label)', defaultLabel), - invalid: boolean('Show form validation UI (invalid)', false), - invalidText: text( - 'Form validation UI content (invalidText)', - 'Invalid Selection' - ), - warn: boolean('Show warning state (warn)', false), - warnText: text( - 'Warning state text (warnText)', - 'Selecting more items may increase processing time' - ), - onChange: action('onChange'), - onMenuChange: action('onMenuChange'), - listBoxMenuIconTranslationIds: object( - 'Listbox menu icon translation IDs (for translateWithId callback)', - { - 'close.menu': 'Close menu', - 'open.menu': 'Open menu', - 'clear.all': 'Clear all', - 'clear.selection': 'Clear selection', - } - ), - selectionFeedback: select( - 'Selection feedback', - ['top', 'fixed', 'top-after-reopen'], - 'top-after-reopen' - ), -}); - -export default { - title: 'Components/MultiSelect', - component: MultiSelect, - subcomponents: { - FilterableMultiSelect, - }, - decorators: [withKnobs], - parameters: { - docs: { - page: mdx, - }, - }, -}; - -export const Default = withReadme(readme, () => { - const { - listBoxMenuIconTranslationIds, - selectionFeedback, - ...multiSelectProps - } = props(); - return ( -
- (item ? item.text : '')} - translateWithId={(id) => listBoxMenuIconTranslationIds[id]} - selectionFeedback={selectionFeedback} - /> -
- ); -}); - -export const Controlled = withReadme(readme, () => { - const { - listBoxMenuIconTranslationIds, - selectionFeedback, - ...multiSelectProps - } = props(); - const [selectedItems, setSelectedItems] = useState([]); - const onChange = useCallback(({ selectedItems: newSelectedItems }) => { - setSelectedItems(newSelectedItems); - }, []); - - return ( -
- (item ? item.text : '')} - translateWithId={(id) => listBoxMenuIconTranslationIds[id]} - selectionFeedback={selectionFeedback} - onChange={onChange} - selectedItems={selectedItems} - /> - - -
- ); -}); - -export const ItemToElement = withReadme(readme, () => { - return ( -
- (item ? item.text : '')} - itemToElement={(item) => - item ? ( - - {item.text}{' '} - - {' '} - 🔥 - - - ) : ( - '' - ) - } - /> -
- (item ? item.text : '')} - itemToElement={(item) => - item ? ( - - {item.text}{' '} - - {' '} - 🔥 - - - ) : ( - '' - ) - } - /> -
- ); -}); - -Default.storyName = 'default'; - -Default.parameters = { - info: { - text: ` - MultiSelect - `, - }, -}; - -export const WithInitialSelectedItems = withReadme(readme, () => { - const { - listBoxMenuIconTranslationIds, - selectionFeedback, - ...multiSelectProps - } = props(); - - return ( -
- (item ? item.text : '')} - initialSelectedItems={[items[0], items[1]]} - translateWithId={(id) => listBoxMenuIconTranslationIds[id]} - selectionFeedback={selectionFeedback} - /> -
- ); -}); - -WithInitialSelectedItems.storyName = 'with initial selected items'; - -WithInitialSelectedItems.parameters = { - info: { - text: ` - Provide a set of items to initially select in the control - `, - }, -}; - -export const _Filterable = withReadme(readme, () => { - const { - listBoxMenuIconTranslationIds, - selectionFeedback, - ...multiSelectProps - } = props(); - - return ( -
- (item ? item.text : '')} - placeholder={defaultPlaceholder} - translateWithId={(id) => listBoxMenuIconTranslationIds[id]} - selectionFeedback={selectionFeedback} - onMenuChange={(e) => { - multiSelectProps.onMenuChange(e); - }} - /> -
- ); -}); - -_Filterable.storyName = 'filterable'; - -_Filterable.parameters = { - info: { - text: ` - When a list contains more than 25 items, use \`MultiSelect.Filterable\` to help find options from the list. - `, - }, -}; - -export const WithChangeOnClose = withReadme(readme, () => { - const { - listBoxMenuIconTranslationIds, - selectionFeedback, - ...multiSelectProps - } = props(); - - const [hasFocus, setHasFocus] = useState(false); - const [active, setActive] = useState(false); - const [selItems, setSelItems] = useState([items[0]]); - if (!hasFocus && active && selItems.length == 0) { - setActive(false); - } - - return ( -
- { - setActive(a); - if (a) { - setSelItems([items[0]]); - } - }} - labelText="Active" - /> - (item ? item.text : '')} - translateWithId={(id) => listBoxMenuIconTranslationIds[id]} - selectionFeedback={selectionFeedback} - key={active} - disabled={!active} - initialSelectedItems={selItems} - onMenuChange={(e) => { - multiSelectProps.onMenuChange(e); - setHasFocus(e); - }} - onChange={(e) => { - setSelItems(e.selectedItems); - }} - /> -
- ); -}); diff --git a/packages/react/src/components/MultiSelect/next/MultiSelect.stories.js b/packages/react/src/components/MultiSelect/MultiSelect.stories.js similarity index 98% rename from packages/react/src/components/MultiSelect/next/MultiSelect.stories.js rename to packages/react/src/components/MultiSelect/MultiSelect.stories.js index b4d3c30cd369..b56d4e975ec4 100644 --- a/packages/react/src/components/MultiSelect/next/MultiSelect.stories.js +++ b/packages/react/src/components/MultiSelect/MultiSelect.stories.js @@ -6,9 +6,9 @@ */ import React from 'react'; -import MultiSelect from '../'; +import MultiSelect from '.'; import FilterableMultiSelect from './FilterableMultiSelect'; -import { Layer } from '../../Layer'; +import { Layer } from '../Layer'; export default { title: 'Components/MultiSelect', diff --git a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.e2e.js b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.e2e.js index bd77bfb9a7e0..2fdfd1deebef 100644 --- a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.e2e.js +++ b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.e2e.js @@ -5,12 +5,12 @@ * LICENSE file in the root directory of this source tree. */ -import '../../../../index.scss'; +import '../../../../../index.scss'; import React from 'react'; import { mount } from '@cypress/react'; import { generateItems, generateGenericItem } from '../../ListBox/test-helpers'; -import FilterableMultiSelect from '../../MultiSelect/FilterableMultiSelect'; +import FilterableMultiSelect from '../FilterableMultiSelect'; describe('FilterableMultiSelect', () => { beforeEach(() => { diff --git a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js index 7ccf35a41f55..633ffa3f5e76 100644 --- a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js +++ b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js @@ -7,7 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import FilterableMultiSelect from '../../MultiSelect/FilterableMultiSelect'; +import FilterableMultiSelect from '../FilterableMultiSelect'; import { assertMenuOpen, assertMenuClosed, @@ -25,8 +25,6 @@ describe('FilterableMultiSelect', () => { let mockProps; beforeEach(() => { - // jest.mock('../../../internal/deprecateFieldOnObject'); - mockProps = { id: 'test-filterable-multiselect', disabled: false, diff --git a/packages/react/src/components/MultiSelect/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap b/packages/react/src/components/MultiSelect/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap index 3a83c2b00d57..166398a5594e 100644 --- a/packages/react/src/components/MultiSelect/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap +++ b/packages/react/src/components/MultiSelect/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`FilterableMultiSelect should render 1`] = ` -
- + `; diff --git a/packages/react/src/components/MultiSelect/index.js b/packages/react/src/components/MultiSelect/index.js index 6a1bd6854598..1d5eedcd0511 100644 --- a/packages/react/src/components/MultiSelect/index.js +++ b/packages/react/src/components/MultiSelect/index.js @@ -5,22 +5,17 @@ * LICENSE file in the root directory of this source tree. */ -import * as FeatureFlags from '@carbon/feature-flags'; import { deprecateFieldOnObject } from '../../internal/deprecateFieldOnObject'; import MultiSelect from './MultiSelect'; -import { default as FilterableMultiSelectClassic } from './FilterableMultiSelect'; -import { default as FilterableMultiSelectNext } from './next/FilterableMultiSelect'; +import { default as FilterableMultiSelect } from './FilterableMultiSelect'; -FilterableMultiSelectNext.displayName = 'MultiSelect.Filterable'; -MultiSelect.Filterable = FilterableMultiSelectClassic; - -export const FilterableMultiSelect = FeatureFlags.enabled('enable-v11-release') - ? FilterableMultiSelectNext - : FilterableMultiSelectClassic; +FilterableMultiSelect.displayName = 'MultiSelect.Filterable'; +MultiSelect.Filterable = FilterableMultiSelect; if (__DEV__) { deprecateFieldOnObject(MultiSelect, 'Filterable', FilterableMultiSelect); } +export { FilterableMultiSelect }; export default MultiSelect; diff --git a/packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js b/packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js deleted file mode 100644 index 6e57bd4343ed..000000000000 --- a/packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js +++ /dev/null @@ -1,600 +0,0 @@ -/** - * 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 { WarningFilled, WarningAltFilled } from '@carbon/icons-react'; -import cx from 'classnames'; -import Downshift from 'downshift'; -import isEqual from 'lodash.isequal'; -import PropTypes from 'prop-types'; -import React, { useState, useRef } from 'react'; -import { defaultFilterItems } from '../../ComboBox/tools/filter'; -import { sortingPropTypes } from '../MultiSelectPropTypes'; -import ListBox, { PropTypes as ListBoxPropTypes } from '../../ListBox'; -import { ListBoxTrigger, ListBoxSelection } from '../../ListBox/next'; -import { match, keys } from '../../../internal/keyboard'; -import Selection from '../../../internal/Selection'; -import { defaultItemToString } from '../tools/itemToString'; -import mergeRefs from '../../../tools/mergeRefs'; -import { useId } from '../../../internal/useId'; -import { defaultSortItems, defaultCompareItems } from '../tools/sorting'; -import { useFeatureFlag } from '../../FeatureFlags'; -import { usePrefix } from '../../../internal/usePrefix'; - -const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect( - { - ariaLabel, - className: containerClassName, - compareItems, - direction, - disabled, - downshiftProps, - filterItems, - helperText, - hideLabel, - id, - initialSelectedItems, - invalid, - invalidText, - items, - itemToElement: ItemToElement, // needs to be capitalized for react to render it correctly - itemToString, - light, - locale, - onInputValueChange, - open, - onChange, - onMenuChange, - placeholder, - titleText, - type, - selectionFeedback, - size, - sortItems, - translateWithId, - useTitleInItem, - warn, - warnText, - }, - ref -) { - const [isOpen, setIsOpen] = useState(open); - const [prevOpen, setPrevOpen] = useState(open); - const [inputValue, setInputValue] = useState(''); - const [topItems, setTopItems] = useState([]); - const [inputFocused, setInputFocused] = useState(false); - const [highlightedIndex, setHighlightedIndex] = useState(null); - const textInput = useRef(); - const filterableMultiSelectInstanceId = useId(); - - const enabled = useFeatureFlag('enable-v11-release'); - const prefix = usePrefix(); - - if (prevOpen !== open) { - setIsOpen(open); - setPrevOpen(open); - } - - const inline = type === 'inline'; - const showWarning = !invalid && warn; - - const wrapperClasses = cx( - `${prefix}--multi-select__wrapper`, - `${prefix}--list-box__wrapper`, - [enabled ? containerClassName : null], - { - [`${prefix}--multi-select__wrapper--inline`]: inline, - [`${prefix}--list-box__wrapper--inline`]: inline, - [`${prefix}--multi-select__wrapper--inline--invalid`]: inline && invalid, - [`${prefix}--list-box__wrapper--inline--invalid`]: inline && invalid, - [`${prefix}--list-box--up`]: direction === 'top', - } - ); - const helperId = !helperText - ? undefined - : `filterablemultiselect-helper-text-${filterableMultiSelectInstanceId}`; - const labelId = `${id}-label`; - const titleClasses = cx({ - [`${prefix}--label`]: true, - [`${prefix}--label--disabled`]: disabled, - [`${prefix}--visually-hidden`]: hideLabel, - }); - const helperClasses = cx({ - [`${prefix}--form__helper-text`]: true, - [`${prefix}--form__helper-text--disabled`]: disabled, - }); - const inputClasses = cx({ - [`${prefix}--text-input`]: true, - [`${prefix}--text-input--empty`]: !inputValue, - [`${prefix}--text-input--light`]: light, - }); - const helper = helperText ? ( -
- {helperText} -
- ) : null; - const menuId = `${id}__menu`; - const inputId = `${id}-input`; - - function handleOnChange(changes) { - if (onChange) { - onChange(changes); - } - } - - function handleOnMenuChange(forceIsOpen) { - const nextIsOpen = forceIsOpen ?? !isOpen; - setIsOpen(nextIsOpen); - if (onMenuChange) { - onMenuChange(nextIsOpen); - } - } - - function handleOnOuterClick() { - handleOnMenuChange(false); - } - - function handleOnStateChange(changes, downshift) { - if (changes.isOpen && !isOpen) { - setTopItems(downshift.selectedItem); - } - - const { type } = changes; - const { stateChangeTypes } = Downshift; - - switch (type) { - case stateChangeTypes.keyDownArrowDown: - case stateChangeTypes.keyDownArrowUp: - case stateChangeTypes.keyDownHome: - case stateChangeTypes.keyDownEnd: - setHighlightedIndex( - changes.highlightedIndex !== undefined - ? changes.highlightedIndex - : null - ); - if (stateChangeTypes.keyDownArrowDown === type && !isOpen) { - handleOnMenuChange(true); - } - break; - case stateChangeTypes.keyDownEscape: - handleOnMenuChange(false); - break; - } - } - - function handleOnInputValueChange(inputValue, { type }) { - if (onInputValueChange) { - onInputValueChange(inputValue); - } - - if (type !== Downshift.stateChangeTypes.changeInput) { - return; - } - - if (Array.isArray(inputValue)) { - clearInputValue(); - } else { - setInputValue(inputValue); - } - - if (inputValue && !isOpen) { - handleOnMenuChange(true); - } else if (!inputValue && isOpen) { - handleOnMenuChange(false); - } - } - - function clearInputValue() { - setInputValue(''); - if (textInput.current) { - textInput.current.focus(); - } - } - - return ( - ( - { - if (selectedItem !== null) { - onItemChange(selectedItem); - } - }} - itemToString={itemToString} - onStateChange={handleOnStateChange} - onOuterClick={handleOnOuterClick} - selectedItem={selectedItems} - labelId={labelId} - menuId={menuId} - inputId={inputId}> - {({ - getInputProps, - getItemProps, - getLabelProps, - getMenuProps, - getRootProps, - getToggleButtonProps, - isOpen, - inputValue, - selectedItem, - }) => { - const className = cx( - `${prefix}--multi-select`, - `${prefix}--combo-box`, - `${prefix}--multi-select--filterable`, - [enabled ? null : containerClassName], - { - [`${prefix}--multi-select--invalid`]: invalid, - [`${prefix}--multi-select--open`]: isOpen, - [`${prefix}--multi-select--inline`]: inline, - [`${prefix}--multi-select--selected`]: selectedItem.length > 0, - [`${prefix}--multi-select--filterable--input-focused`]: - inputFocused, - } - ); - const rootProps = getRootProps( - {}, - { - suppressRefError: true, - } - ); - - const labelProps = getLabelProps(); - - const buttonProps = getToggleButtonProps({ - disabled, - onClick: () => { - handleOnMenuChange(!isOpen); - if (textInput.current) { - textInput.current.focus(); - } - }, - // When we moved the "root node" of Downshift to the for - // ARIA 1.2 compliance, we unfortunately hit this branch for the - // "mouseup" event that downshift listens to: - // https://github.com/downshift-js/downshift/blob/v5.2.1/src/downshift.js#L1051-L1065 - // - // As a result, it will reset the state of the component and so we - // stop the event from propagating to prevent this. This allows the - // toggleMenu behavior for the toggleButton to correctly open and - // close the menu. - onMouseUp(event) { - if (isOpen) { - event.stopPropagation(); - } - }, - }); - - const inputProps = getInputProps({ - 'aria-controls': isOpen ? menuId : null, - 'aria-describedby': helperText ? helperId : null, - // Remove excess aria `aria-labelledby`. HTML - )} - /> - ); -}); - -FilterableMultiSelect.propTypes = { - /** - * 'aria-label' of the ListBox component. - */ - ariaLabel: PropTypes.string, - - /** - * Specify the direction of the multiselect dropdown. Can be either top or bottom. - */ - direction: PropTypes.oneOf(['top', 'bottom']), - - /** - * Disable the control - */ - disabled: PropTypes.bool, - - /** - * Additional props passed to Downshift - */ - downshiftProps: PropTypes.shape(Downshift.propTypes), - - /** - * Specify whether the title text should be hidden or not - */ - hideLabel: PropTypes.bool, - - /** - * Specify a custom `id` - */ - id: PropTypes.string.isRequired, - - /** - * Allow users to pass in arbitrary items from their collection that are - * pre-selected - */ - initialSelectedItems: PropTypes.array, - - /** - * Is the current selection invalid? - */ - invalid: PropTypes.bool, - - /** - * If invalid, what is the error? - */ - invalidText: PropTypes.node, - - /** - * Function to render items as custom components instead of strings. - * Defaults to null and is overridden by a getter - */ - itemToElement: PropTypes.func, - - /** - * Helper function passed to downshift that allows the library to render a - * given item to a string label. By default, it extracts the `label` field - * from a given item to serve as the item label in the list. - */ - itemToString: PropTypes.func, - - /** - * We try to stay as generic as possible here to allow individuals to pass - * in a collection of whatever kind of data structure they prefer - */ - items: PropTypes.array.isRequired, - - /** - * `true` to use the light version. - */ - light: PropTypes.bool, - - /** - * Specify the locale of the control. Used for the default `compareItems` - * used for sorting the list of items in the control. - */ - locale: PropTypes.string, - - /** - * `onChange` is a utility for this controlled component to communicate to a - * consuming component what kind of internal state changes are occurring. - */ - onChange: PropTypes.func, - - /** - * `onInputValueChange` is a utility for this controlled component to communicate to - * the currently typed input. - */ - onInputValueChange: PropTypes.func, - - /** - * `onMenuChange` is a utility for this controlled component to communicate to a - * consuming component that the menu was opened(`true`)/closed(`false`). - */ - onMenuChange: PropTypes.func, - - /** - * Initialize the component with an open(`true`)/closed(`false`) menu. - */ - open: PropTypes.bool, - - /** - * Generic `placeholder` that will be used as the textual representation of - * what this field is for - */ - placeholder: PropTypes.string, - - /** - * Specify feedback (mode) of the selection. - * `top`: selected item jumps to top - * `fixed`: selected item stays at it's position - * `top-after-reopen`: selected item jump to top after reopen dropdown - */ - selectionFeedback: PropTypes.oneOf(['top', 'fixed', 'top-after-reopen']), - - /** - * Specify the size of the ListBox. Currently supports either `sm`, `md` or `lg` as an option. - */ - size: ListBoxPropTypes.ListBoxSize, - - ...sortingPropTypes, - - /** - * Callback function for translating ListBoxMenuIcon SVG title - */ - translateWithId: PropTypes.func, - - /** - * Specify title to show title on hover - */ - useTitleInItem: PropTypes.bool, - - /** - * Specify whether the control is currently in warning state - */ - warn: PropTypes.bool, - - /** - * Provide the text that is displayed when the control is in warning state - */ - warnText: PropTypes.node, -}; - -FilterableMultiSelect.defaultProps = { - ariaLabel: 'Choose an item', - compareItems: defaultCompareItems, - direction: 'bottom', - disabled: false, - filterItems: defaultFilterItems, - initialSelectedItems: [], - itemToString: defaultItemToString, - locale: 'en', - sortItems: defaultSortItems, - light: false, - open: false, - selectionFeedback: 'top-after-reopen', -}; - -export default FilterableMultiSelect; diff --git a/packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.e2e.js b/packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.e2e.js deleted file mode 100644 index 4adee9dd266b..000000000000 --- a/packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.e2e.js +++ /dev/null @@ -1,136 +0,0 @@ -/** - * 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 '../../../../../index.scss'; - -import React from 'react'; -import { mount } from '@cypress/react'; -import { - generateItems, - generateGenericItem, -} from '../../../ListBox/test-helpers'; -import FilterableMultiSelect from '../FilterableMultiSelect'; - -describe('FilterableMultiSelect', () => { - beforeEach(() => { - const items = generateItems(5, generateGenericItem); - const placeholder = 'Placeholder...'; - - // eslint-disable-next-line react/prop-types - function WrappedFilterableMultiSelect({ marginBottom = '1rem', ...props }) { - return ( -
- -
- ); - } - - mount( - <> - - - - - - - - - - - - - - - ); - }); - - it('should render', () => { - cy.findAllByPlaceholderText(/Placeholder.../) - .should('have.length', 13) - .last() - .should('be.visible'); - - // snapshots should always be taken _after_ an assertion that - // a element/component should be visible. This is to ensure - // the DOM has settled and the element has fully loaded. - cy.percySnapshot(); - }); - - it('should render listbox when clicked', () => { - cy.findAllByPlaceholderText(/Placeholder.../) - .first() - .click(); - - cy.findAllByText(/Item 0/) - .first() - .should('be.visible'); - cy.findAllByText(/Item 4/) - .first() - .should('be.visible'); - - // snapshots should always be taken _after_ an assertion that - // a element/component should be visible. This is to ensure - // the DOM has settled and the element has fully loaded. - cy.percySnapshot(); - }); -}); diff --git a/packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.js b/packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.js deleted file mode 100644 index 9633bb65af72..000000000000 --- a/packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * 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 { mount } from 'enzyme'; -import FilterableMultiSelect from '../FilterableMultiSelect'; -import { - assertMenuOpen, - assertMenuClosed, - findMenuIconNode, - generateItems, - generateGenericItem, -} from '../../../ListBox/test-helpers'; - -const listItemName = 'ListBoxMenuItem'; -const openMenu = (wrapper) => { - wrapper.find(`[role="combobox"]`).simulate('click'); -}; - -describe('FilterableMultiSelect', () => { - let mockProps; - - beforeEach(() => { - mockProps = { - id: 'test-filterable-multiselect', - disabled: false, - items: generateItems(5, generateGenericItem), - initialSelectedItems: [], - onChange: jest.fn(), - onMenuChange: jest.fn(), - placeholder: 'Placeholder...', - }; - }); - - it('should render', () => { - const wrapper = mount(); - expect(wrapper).toMatchSnapshot(); - }); - - it('should display all items when the menu is open initially', () => { - const wrapper = mount(); - openMenu(wrapper); - expect(wrapper.find(listItemName).length).toBe(mockProps.items.length); - }); - - it('should initially have the menu open when open prop is provided', () => { - const wrapper = mount(); - assertMenuOpen(wrapper, mockProps); - }); - - it('should open the menu with a down arrow', () => { - const wrapper = mount(); - const menuIconNode = findMenuIconNode(wrapper); - - menuIconNode.simulate('keyDown', { key: 'ArrowDown' }); - assertMenuOpen(wrapper, mockProps); - }); - - it('should let the user toggle the menu by the menu icon', () => { - const wrapper = mount(); - findMenuIconNode(wrapper).simulate('click'); - assertMenuOpen(wrapper, mockProps); - findMenuIconNode(wrapper).simulate('click'); - assertMenuClosed(wrapper); - }); - - it('should not close the menu after a user makes a selection', () => { - const wrapper = mount(); - openMenu(wrapper); - - const firstListItem = wrapper.find(listItemName).at(0); - - firstListItem.simulate('click'); - assertMenuOpen(wrapper, mockProps); - }); - - it('should filter a list of items by the input value', () => { - const wrapper = mount(); - openMenu(wrapper); - expect(wrapper.find(listItemName).length).toBe(mockProps.items.length); - - wrapper - .find('[placeholder="Placeholder..."]') - .at(1) - .simulate('change', { target: { value: '3' } }); - - expect(wrapper.find(listItemName).length).toBe(1); - }); - - it('should call `onChange` with each update to selected items', () => { - const wrapper = mount( - - ); - openMenu(wrapper); - - // Select the first two items - wrapper.find(listItemName).at(0).simulate('click'); - - expect(mockProps.onChange).toHaveBeenCalledTimes(1); - expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[0]], - }); - - wrapper.find(listItemName).at(1).simulate('click'); - - expect(mockProps.onChange).toHaveBeenCalledTimes(2); - expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[0], mockProps.items[1]], - }); - - // Un-select the next two items - wrapper.find(listItemName).at(0).simulate('click'); - expect(mockProps.onChange).toHaveBeenCalledTimes(3); - expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[0]], - }); - - wrapper.find(listItemName).at(0).simulate('click'); - expect(mockProps.onChange).toHaveBeenCalledTimes(4); - expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [], - }); - }); - - it('should let items stay at their position after selecting', () => { - const wrapper = mount( - - ); - openMenu(wrapper); - - // Select the first two items - wrapper.find(listItemName).at(1).simulate('click'); - - expect(mockProps.onChange).toHaveBeenCalledTimes(1); - expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [mockProps.items[1]], - }); - - wrapper.find(listItemName).at(1).simulate('click'); - - expect(mockProps.onChange).toHaveBeenCalledTimes(2); - expect(mockProps.onChange).toHaveBeenCalledWith({ - selectedItems: [], - }); - }); - - it('should not clear input value after a user makes a selection', () => { - const wrapper = mount(); - openMenu(wrapper); - - wrapper - .find('[placeholder="Placeholder..."]') - .at(1) - .simulate('change', { target: { value: '3' } }); - - wrapper.find(listItemName).at(0).simulate('click'); - - expect( - wrapper.find('[placeholder="Placeholder..."]').at(1).props().value - ).toEqual('3'); - }); -}); diff --git a/packages/react/src/components/MultiSelect/next/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap b/packages/react/src/components/MultiSelect/next/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap deleted file mode 100644 index 166398a5594e..000000000000 --- a/packages/react/src/components/MultiSelect/next/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap +++ /dev/null @@ -1,187 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FilterableMultiSelect should render 1`] = ` - - - -
- -
-
- - - - -
-
-
-
-
-
-
-`; From c7c0c456bca6bff7386921530b3d1bca07fabfed Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 13 Jul 2022 17:50:31 -0500 Subject: [PATCH 06/16] refactor(react): update radio button tests (#11790) * refactor(react): update radio button group tests * refactor(react): update radio button tests * docs(react): update radio button docs * test(react): update snapshots Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../__snapshots__/PublicAPI-test.js.snap | 1 - .../__snapshots__/DataTable-test.js.snap | 222 ++++++-------- .../RadioButton/RadioButton-story.js | 106 ------- .../RadioButton/RadioButton-test.js | 160 ---------- .../src/components/RadioButton/RadioButton.js | 268 ++++++++-------- .../{next => }/RadioButton.stories.js | 26 +- .../RadioButton/__tests__/RadioButton-test.js | 135 +++++++++ .../__tests__/RadioButtonSkeleton-test.js | 24 ++ .../react/src/components/RadioButton/index.js | 9 +- .../RadioButton/next/RadioButton-test.js | 160 ---------- .../RadioButton/next/RadioButton.js | 131 -------- .../RadioButtonGroup/RadioButtonGroup-test.js | 100 +++--- .../RadioButtonGroup/RadioButtonGroup.js | 285 +++++++----------- .../src/components/RadioButtonGroup/index.js | 8 +- .../next/RadioButtonGroup-test.js | 226 -------------- .../RadioButtonGroup/next/RadioButtonGroup.js | 132 -------- 16 files changed, 558 insertions(+), 1435 deletions(-) delete mode 100644 packages/react/src/components/RadioButton/RadioButton-story.js delete mode 100644 packages/react/src/components/RadioButton/RadioButton-test.js rename packages/react/src/components/RadioButton/{next => }/RadioButton.stories.js (52%) create mode 100644 packages/react/src/components/RadioButton/__tests__/RadioButton-test.js create mode 100644 packages/react/src/components/RadioButton/__tests__/RadioButtonSkeleton-test.js delete mode 100644 packages/react/src/components/RadioButton/next/RadioButton-test.js delete mode 100644 packages/react/src/components/RadioButton/next/RadioButton.js delete mode 100644 packages/react/src/components/RadioButtonGroup/next/RadioButtonGroup-test.js delete mode 100644 packages/react/src/components/RadioButtonGroup/next/RadioButtonGroup.js diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index c7c625f7aec7..d1f84f0524bd 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -5324,7 +5324,6 @@ Map { }, ], ], - "isRequired": true, "type": "oneOfType", }, }, diff --git a/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap b/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap index a4470d90c119..550867deb6e3 100644 --- a/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap +++ b/packages/react/src/components/DataTable/__tests__/__snapshots__/DataTable-test.js.snap @@ -112,13 +112,13 @@ exports[`DataTable selection -- radio buttons should not have select-all checkbo >

DataTable with selection

@@ -556,7 +556,7 @@ exports[`DataTable selection -- radio buttons should render 1`] = ` - - -
+
-
-
+ Select row + + + + + - - -
+
-
-
+ Select row + + + + +

DataTable with toolbar

- - -
+
-
-
+ Select row + + + + +
diff --git a/packages/react/src/components/RadioButton/RadioButton-story.js b/packages/react/src/components/RadioButton/RadioButton-story.js deleted file mode 100644 index d21143dbf169..000000000000 --- a/packages/react/src/components/RadioButton/RadioButton-story.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * 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 { action } from '@storybook/addon-actions'; - -import { withKnobs, boolean, select, text } from '@storybook/addon-knobs'; -import RadioButtonGroup from '../RadioButtonGroup'; -import RadioButton from '../RadioButton'; -import mdx from './RadioButton.mdx'; - -const values = { - 'Option 1': 'radio-1', - 'Option 2': 'radio-2', - 'Option 3': 'radio-3', -}; - -const orientations = { - 'Horizontal (horizontal)': 'horizontal', - 'Vertical (vertical)': 'vertical', -}; - -const labelPositions = { - 'Left (left)': 'left', - 'Right (right)': 'right', -}; - -const props = { - group: () => ({ - hideLegend: boolean( - 'Hide the legend (hideLegend) of the RadioButtonGroup (legendText)', - false - ), - legendText: text( - 'The label (legend) of the RadioButtonGroup (legendText)', - 'Radio button heading' - ), - name: text( - 'The form control name (name in )', - 'radio-button-group' - ), - valueSelected: select( - 'Value of the selected button (valueSelected in )', - values, - 'radio-3' - ), - orientation: select( - 'Radio button orientation (orientation)', - orientations, - 'horizontal' - ), - labelPosition: select( - 'Label position (labelPosition)', - labelPositions, - 'right' - ), - onChange: action('onChange'), - }), - radio: () => ({ - className: 'some-class', - disabled: boolean('Disabled (disabled in )', false), - labelText: text('The label of the RadioButton (labelText)', 'Option 1'), - }), -}; - -export default { - title: 'Components/RadioButton', - component: RadioButtonGroup, - decorators: [withKnobs], - parameters: { - docs: { - page: mdx, - }, - }, - subcomponents: { - RadioButton, - }, -}; - -export const Default = () => { - return ( - - - - - - ); -}; - -export const Playground = () => { - const radioProps = props.radio(); - return ( - - - - - - ); -}; diff --git a/packages/react/src/components/RadioButton/RadioButton-test.js b/packages/react/src/components/RadioButton/RadioButton-test.js deleted file mode 100644 index 25dc7101a211..000000000000 --- a/packages/react/src/components/RadioButton/RadioButton-test.js +++ /dev/null @@ -1,160 +0,0 @@ -/** - * 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 RadioButton from '../RadioButton'; -import RadioButtonSkeleton from '../RadioButton/RadioButton.Skeleton'; -import { mount, shallow } from 'enzyme'; - -const prefix = 'cds'; - -const render = (props) => - mount( - - ); - -describe('RadioButton', () => { - describe('renders as expected', () => { - const wrapper = render({ - checked: true, - }); - - const input = wrapper.find('input'); - const label = wrapper.find('label'); - const div = wrapper.find('div'); - - describe('input', () => { - it('is of type radio', () => { - expect(input.props().type).toEqual('radio'); - }); - - it('has the expected class', () => { - expect(input.hasClass(`${prefix}--radio-button`)).toEqual(true); - }); - - it('has a unique id set by default', () => { - expect(input.props().id).toBeDefined(); - }); - - it('should have checked set when checked is passed', () => { - wrapper.setProps({ checked: true }); - expect(input.props().checked).toEqual(true); - }); - - it('should set the name prop as expected', () => { - expect(input.props().name).toEqual('test-name'); - }); - }); - - describe('label', () => { - it('should set htmlFor', () => { - expect(label.props().htmlFor).toEqual(input.props().id); - }); - - it('should set the correct class', () => { - expect(label.props().className).toEqual( - `${prefix}--radio-button__label` - ); - }); - - it('should render a span with the correct class', () => { - const span = label.find('span'); - expect( - span.at(0).hasClass(`${prefix}--radio-button__appearance`) - ).toEqual(true); - }); - - it('should render a span for the label text', () => { - const span = label.find('span'); - expect(span.at(1).hasClass('')).toEqual(true); - expect(span.at(1).text()).toEqual('testlabel'); - }); - - it('should render a span with hidden class name to hide label text', () => { - wrapper.setProps({ - hideLabel: true, - }); - const label = wrapper.find('span'); - const span = label.find('span'); - expect(span.at(1).hasClass(`${prefix}--visually-hidden`)).toEqual(true); - expect(span.at(1).text()).toEqual('testlabel'); - }); - - it('should render label text', () => { - wrapper.setProps({ labelText: 'test label text' }); - expect(label.text()).toMatch(/test label text/); - }); - }); - - describe('wrapper', () => { - it('should have the correct class', () => { - expect(div.hasClass(`${prefix}--radio-button-wrapper`)).toEqual(true); - }); - - it('should have extra classes applied', () => { - expect(div.hasClass('extra-class')).toEqual(true); - }); - }); - }); - - it('should set defaultChecked as expected', () => { - const wrapper = render({ - defaultChecked: true, - }); - - const input = () => wrapper.find('input'); - expect(input().props().defaultChecked).toEqual(true); - wrapper.setProps({ defaultChecked: false }); - expect(input().props().defaultChecked).toEqual(false); - }); - - it('should set id if one is passed in', () => { - const wrapper = render({ - id: 'unique-id', - }); - - const input = wrapper.find('input'); - expect(input.props().id).toEqual('unique-id'); - }); - - describe('events', () => { - it('should invoke onChange with expected arguments', () => { - const onChange = jest.fn(); - const wrapper = render({ onChange }); - const input = wrapper.find('input'); - const inputElement = input.instance(); - - inputElement.checked = true; - wrapper.find('input').simulate('change'); - - const call = onChange.mock.calls[0]; - - expect(call[0]).toEqual('test-value'); - expect(call[1]).toEqual('test-name'); - expect(call[2].target).toBe(inputElement); - }); - }); -}); - -describe('RadioButtonSkeleton', () => { - describe('Renders as expected', () => { - const wrapper = shallow(); - - const label = wrapper.find('span'); - - it('Has the expected classes', () => { - expect(label.hasClass(`${prefix}--skeleton`)).toEqual(true); - expect(label.hasClass(`${prefix}--radio-button__label`)).toEqual(true); - }); - }); -}); diff --git a/packages/react/src/components/RadioButton/RadioButton.js b/packages/react/src/components/RadioButton/RadioButton.js index 8050e5aa5955..9f9424334d37 100644 --- a/packages/react/src/components/RadioButton/RadioButton.js +++ b/packages/react/src/components/RadioButton/RadioButton.js @@ -5,147 +5,135 @@ * LICENSE file in the root directory of this source tree. */ -import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; -import { warning } from '../../internal/warning'; -import uid from '../../tools/uniqueId'; +import classNames from 'classnames'; import { Text } from '../Text'; -import { PrefixContext } from '../../internal/usePrefix'; - -class RadioButton extends React.Component { - static propTypes = { - /** - * Specify whether the is currently checked - */ - checked: PropTypes.bool, - - /** - * Provide an optional className to be applied to the containing node - */ - className: PropTypes.string, - - /** - * Specify whether the should be checked by default - */ - defaultChecked: PropTypes.bool, - - /** - * Specify whether the control is disabled - */ - disabled: PropTypes.bool, - - /** - * Specify whether the label should be hidden, or not - */ - hideLabel: PropTypes.bool, - - /** - * Provide a unique id for the underlying `` node - */ - id: PropTypes.string, - - /** - * Provide where label text should be placed - * NOTE: `top`/`bottom` are deprecated - */ - labelPosition: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), - - /** - * Provide label text to be read by screen readers when interacting with the - * control - */ - labelText: PropTypes.node.isRequired, - - /** - * Provide a name for the underlying `` node - */ - name: PropTypes.string, - - /** - * Provide an optional `onChange` hook that is called each time the value of - * the underlying `` changes - */ - onChange: PropTypes.func, - - /** - * Provide a handler that is invoked when a user clicks on the control - */ - onClick: PropTypes.func, - - /** - * Specify the value of the - */ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - }; - - static contextType = PrefixContext; - prefix = this.context; - - static defaultProps = { - labelText: '', - labelPosition: 'right', - onChange: () => {}, - value: '', - }; - - uid = this.props.id || uid(); - - handleChange = (evt) => { - this.props.onChange(this.props.value, this.props.name, evt); - }; - - render() { - const prefix = this.prefix; - const { - className, - labelText, - labelPosition, - // eslint-disable-next-line react/prop-types - innerRef: ref, - hideLabel, - ...other - } = this.props; - if (__DEV__) { - warning( - labelPosition !== 'top' && labelPosition !== 'bottom', - '`top`/`bottom` values for `labelPosition` property in the `RadioButton` component is deprecated ' + - 'and being removed in the next release of `carbon-components-react`.' - ); - } - const innerLabelClasses = classNames({ - [`${prefix}--visually-hidden`]: hideLabel, - }); - const wrapperClasses = classNames( - className, - `${prefix}--radio-button-wrapper`, - { - [`${prefix}--radio-button-wrapper--label-${labelPosition}`]: - labelPosition !== 'right', - } - ); - return ( -
- - -
- ); +import { usePrefix } from '../../internal/usePrefix'; +import { useId } from '../../internal/useId'; + +const RadioButton = React.forwardRef(function RadioButton( + { + className, + disabled, + hideLabel, + id, + labelPosition = 'right', + labelText = '', + name, + onChange = () => {}, + value = '', + ...rest + }, + ref +) { + const prefix = usePrefix(); + const uid = useId('radio-button'); + const uniqueId = id || uid; + + function handleOnChange(event) { + onChange(value, name, event); } -} - -export { RadioButton }; -export default (() => { - const forwardRef = (props, ref) => ; - forwardRef.displayName = 'RadioButton'; - return React.forwardRef(forwardRef); -})(); + + const innerLabelClasses = classNames({ + [`${prefix}--visually-hidden`]: hideLabel, + }); + + const wrapperClasses = classNames( + className, + `${prefix}--radio-button-wrapper`, + { + [`${prefix}--radio-button-wrapper--label-${labelPosition}`]: + labelPosition !== 'right', + } + ); + + return ( +
+ + +
+ ); +}); + +RadioButton.displayName = 'RadioButton'; + +RadioButton.propTypes = { + /** + * Specify whether the `` is currently checked + */ + checked: PropTypes.bool, + + /** + * Provide an optional className to be applied to the containing node + */ + className: PropTypes.string, + + /** + * Specify whether the `` should be checked by default + */ + defaultChecked: PropTypes.bool, + + /** + * Specify whether the control is disabled + */ + disabled: PropTypes.bool, + + /** + * Specify whether the label should be hidden, or not + */ + hideLabel: PropTypes.bool, + + /** + * Provide a unique id for the underlying `` node + */ + id: PropTypes.string, + + /** + * Provide where label text should be placed + * NOTE: `top`/`bottom` are deprecated + */ + labelPosition: PropTypes.oneOf(['right', 'left']), + + /** + * Provide label text to be read by screen readers when interacting with the + * control + */ + labelText: PropTypes.node.isRequired, + + /** + * Provide a name for the underlying `` node + */ + name: PropTypes.string, + + /** + * Provide an optional `onChange` hook that is called each time the value of + * the underlying `` changes + */ + onChange: PropTypes.func, + + /** + * Provide a handler that is invoked when a user clicks on the control + */ + onClick: PropTypes.func, + + /** + * Specify the value of the `` + */ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; + +export default RadioButton; diff --git a/packages/react/src/components/RadioButton/next/RadioButton.stories.js b/packages/react/src/components/RadioButton/RadioButton.stories.js similarity index 52% rename from packages/react/src/components/RadioButton/next/RadioButton.stories.js rename to packages/react/src/components/RadioButton/RadioButton.stories.js index 2882e29db5e8..0e5b71a09c16 100644 --- a/packages/react/src/components/RadioButton/next/RadioButton.stories.js +++ b/packages/react/src/components/RadioButton/RadioButton.stories.js @@ -5,15 +5,23 @@ * LICENSE file in the root directory of this source tree. */ -import RadioButton from '../'; -import RadioButtonGroup from '../../RadioButtonGroup'; +import RadioButton from './RadioButton'; +import RadioButtonGroup from '../RadioButtonGroup'; +import RadioButtonSkeleton from './RadioButton.Skeleton'; import React from 'react'; +import mdx from './RadioButton.mdx'; export default { title: 'Components/RadioButton', component: RadioButton, subcomponents: { RadioButtonGroup, + RadioButtonSkeleton, + }, + parameters: { + docs: { + page: mdx, + }, }, }; @@ -29,3 +37,17 @@ export const Default = () => {
); }; + +export const Skeleton = () => { + return ; +}; + +export const Playground = (args) => { + return ( + + + + + + ); +}; diff --git a/packages/react/src/components/RadioButton/__tests__/RadioButton-test.js b/packages/react/src/components/RadioButton/__tests__/RadioButton-test.js new file mode 100644 index 000000000000..2c399fa22689 --- /dev/null +++ b/packages/react/src/components/RadioButton/__tests__/RadioButton-test.js @@ -0,0 +1,135 @@ +/** + * 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 { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import RadioButton from '../RadioButton'; + +describe('RadioButton', () => { + it('should render an input with type="radio"', () => { + render( + + ); + expect(screen.getByRole('radio')).toBeInTheDocument(); + }); + + it('should set an id on the by default', () => { + render( + + ); + expect(screen.getByRole('radio')).toHaveAttribute('id'); + }); + + it('should set checked on the when checked is provided', () => { + render( + + ); + expect(screen.getByRole('radio')).toBeChecked(); + }); + + it('should label the with labelText', () => { + render( + + ); + expect(screen.getByRole('radio')).toEqual( + screen.getByLabelText('test-label') + ); + }); + + it('should set defaultChecked as expected', () => { + render( + + ); + + expect(screen.getByRole('radio')).toBeChecked(); + }); + + it('should set id on the if one is passed in', () => { + render( + + ); + + expect(screen.getByRole('radio')).toHaveAttribute('id', 'test-id'); + }); + + it('should invoke onChange with expected arguments', () => { + const onChange = jest.fn(); + + render( + + ); + + userEvent.click(screen.getByRole('radio')); + + expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith( + 'test-value', + 'test-name', + expect.objectContaining({ + target: screen.getByRole('radio'), + }) + ); + }); + + it('should place className on the outermost element', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('should spread additional props on the element', () => { + render( + + ); + expect(screen.getByRole('radio')).toHaveAttribute('data-testid', 'test'); + }); + + it('should support a `ref` on the element', () => { + const ref = jest.fn(); + render( + + ); + expect(ref).toHaveBeenCalledWith(screen.getByRole('radio')); + }); +}); diff --git a/packages/react/src/components/RadioButton/__tests__/RadioButtonSkeleton-test.js b/packages/react/src/components/RadioButton/__tests__/RadioButtonSkeleton-test.js new file mode 100644 index 000000000000..d2277da5032f --- /dev/null +++ b/packages/react/src/components/RadioButton/__tests__/RadioButtonSkeleton-test.js @@ -0,0 +1,24 @@ +/** + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import RadioButtonSkeleton from '../RadioButton.Skeleton'; + +describe('RadioButtonSkeleton', () => { + it('should support `className` on the outermost element', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('should spread props on the outermost element', () => { + const { container } = render(); + expect(container.firstChild).toHaveAttribute('data-testid', 'test'); + }); +}); diff --git a/packages/react/src/components/RadioButton/index.js b/packages/react/src/components/RadioButton/index.js index 4dbf158df0c5..b7a2f5828e63 100644 --- a/packages/react/src/components/RadioButton/index.js +++ b/packages/react/src/components/RadioButton/index.js @@ -5,13 +5,6 @@ * LICENSE file in the root directory of this source tree. */ -import * as FeatureFlags from '@carbon/feature-flags'; -import { default as RadioButtonNext } from './next/RadioButton'; -import { default as RadioButtonClassic } from './RadioButton'; - -const RadioButton = FeatureFlags.enabled('enable-v11-release') - ? RadioButtonNext - : RadioButtonClassic; +import RadioButton from './RadioButton'; export default RadioButton; -export { default as RadioButtonSkeleton } from './RadioButton.Skeleton'; diff --git a/packages/react/src/components/RadioButton/next/RadioButton-test.js b/packages/react/src/components/RadioButton/next/RadioButton-test.js deleted file mode 100644 index 35c83db00c0a..000000000000 --- a/packages/react/src/components/RadioButton/next/RadioButton-test.js +++ /dev/null @@ -1,160 +0,0 @@ -/** - * 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 RadioButton from '../RadioButton'; -import RadioButtonSkeleton from '../../RadioButton/RadioButton.Skeleton'; -import { mount, shallow } from 'enzyme'; - -const prefix = 'cds'; - -const render = (props) => - mount( - - ); - -describe('RadioButton', () => { - describe('renders as expected', () => { - const wrapper = render({ - checked: true, - }); - - const input = wrapper.find('input'); - const label = wrapper.find('label'); - const div = wrapper.find('div'); - - describe('input', () => { - it('is of type radio', () => { - expect(input.props().type).toEqual('radio'); - }); - - it('has the expected class', () => { - expect(input.hasClass(`${prefix}--radio-button`)).toEqual(true); - }); - - it('has a unique id set by default', () => { - expect(input.props().id).toBeDefined(); - }); - - it('should have checked set when checked is passed', () => { - wrapper.setProps({ checked: true }); - expect(input.props().checked).toEqual(true); - }); - - it('should set the name prop as expected', () => { - expect(input.props().name).toEqual('test-name'); - }); - }); - - describe('label', () => { - it('should set htmlFor', () => { - expect(label.props().htmlFor).toEqual(input.props().id); - }); - - it('should set the correct class', () => { - expect(label.props().className).toEqual( - `${prefix}--radio-button__label` - ); - }); - - it('should render a span with the correct class', () => { - const span = label.find('span'); - expect( - span.at(0).hasClass(`${prefix}--radio-button__appearance`) - ).toEqual(true); - }); - - it('should render a span for the label text', () => { - const span = label.find('span'); - expect(span.at(1).hasClass('')).toEqual(true); - expect(span.at(1).text()).toEqual('testlabel'); - }); - - it('should render a span with hidden class name to hide label text', () => { - wrapper.setProps({ - hideLabel: true, - }); - const label = wrapper.find('span'); - const span = label.find('span'); - expect(span.at(1).hasClass(`${prefix}--visually-hidden`)).toEqual(true); - expect(span.at(1).text()).toEqual('testlabel'); - }); - - it('should render label text', () => { - wrapper.setProps({ labelText: 'test label text' }); - expect(label.text()).toMatch(/test label text/); - }); - }); - - describe('wrapper', () => { - it('should have the correct class', () => { - expect(div.hasClass(`${prefix}--radio-button-wrapper`)).toEqual(true); - }); - - it('should have extra classes applied', () => { - expect(div.hasClass('extra-class')).toEqual(true); - }); - }); - }); - - it('should set defaultChecked as expected', () => { - const wrapper = render({ - defaultChecked: true, - }); - - const input = () => wrapper.find('input'); - expect(input().props().defaultChecked).toEqual(true); - wrapper.setProps({ defaultChecked: false }); - expect(input().props().defaultChecked).toEqual(false); - }); - - it('should set id if one is passed in', () => { - const wrapper = render({ - id: 'unique-id', - }); - - const input = wrapper.find('input'); - expect(input.props().id).toEqual('unique-id'); - }); - - describe('events', () => { - it('should invoke onChange with expected arguments', () => { - const onChange = jest.fn(); - const wrapper = render({ onChange }); - const input = wrapper.find('input'); - const inputElement = input.instance(); - - inputElement.checked = true; - wrapper.find('input').simulate('change'); - - const call = onChange.mock.calls[0]; - - expect(call[0]).toEqual('test-value'); - expect(call[1]).toEqual('test-name'); - expect(call[2].target).toBe(inputElement); - }); - }); -}); - -describe('RadioButtonSkeleton', () => { - describe('Renders as expected', () => { - const wrapper = shallow(); - - const label = wrapper.find('span'); - - it('Has the expected classes', () => { - expect(label.hasClass(`${prefix}--skeleton`)).toEqual(true); - expect(label.hasClass(`${prefix}--radio-button__label`)).toEqual(true); - }); - }); -}); diff --git a/packages/react/src/components/RadioButton/next/RadioButton.js b/packages/react/src/components/RadioButton/next/RadioButton.js deleted file mode 100644 index 395bde53f647..000000000000 --- a/packages/react/src/components/RadioButton/next/RadioButton.js +++ /dev/null @@ -1,131 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import classNames from 'classnames'; -import { usePrefix } from '../../../internal/usePrefix'; -import { useId } from '../../../internal/useId'; - -import { Text } from '../../Text'; - -const RadioButton = React.forwardRef(function RadioButton( - { - className, - disabled, - hideLabel, - id, - labelPosition = 'right', - labelText = '', - name, - onChange = () => {}, - value = '', - ...rest - }, - ref -) { - const prefix = usePrefix(); - const uid = useId('radio-button'); - const uniqueId = id || uid; - - function handleOnChange(event) { - onChange(value, name, event); - } - - const innerLabelClasses = classNames({ - [`${prefix}--visually-hidden`]: hideLabel, - }); - - const wrapperClasses = classNames( - className, - `${prefix}--radio-button-wrapper`, - { - [`${prefix}--radio-button-wrapper--label-${labelPosition}`]: - labelPosition !== 'right', - } - ); - - return ( -
- - -
- ); -}); - -RadioButton.propTypes = { - /** - * Specify whether the `` is currently checked - */ - checked: PropTypes.bool, - - /** - * Provide an optional className to be applied to the containing node - */ - className: PropTypes.string, - - /** - * Specify whether the `` should be checked by default - */ - defaultChecked: PropTypes.bool, - - /** - * Specify whether the control is disabled - */ - disabled: PropTypes.bool, - - /** - * Specify whether the label should be hidden, or not - */ - hideLabel: PropTypes.bool, - - /** - * Provide a unique id for the underlying `` node - */ - id: PropTypes.string, - - /** - * Provide where label text should be placed - * NOTE: `top`/`bottom` are deprecated - */ - labelPosition: PropTypes.oneOf(['right', 'left']), - - /** - * Provide label text to be read by screen readers when interacting with the - * control - */ - labelText: PropTypes.node.isRequired, - - /** - * Provide a name for the underlying `` node - */ - name: PropTypes.string, - - /** - * Provide an optional `onChange` hook that is called each time the value of - * the underlying `` changes - */ - onChange: PropTypes.func, - - /** - * Provide a handler that is invoked when a user clicks on the control - */ - onClick: PropTypes.func, - - /** - * Specify the value of the `` - */ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, -}; - -export default RadioButton; diff --git a/packages/react/src/components/RadioButtonGroup/RadioButtonGroup-test.js b/packages/react/src/components/RadioButtonGroup/RadioButtonGroup-test.js index 44a3720afd81..6616b7cad74e 100644 --- a/packages/react/src/components/RadioButtonGroup/RadioButtonGroup-test.js +++ b/packages/react/src/components/RadioButtonGroup/RadioButtonGroup-test.js @@ -6,9 +6,9 @@ */ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { shallow, mount } from 'enzyme'; -import RadioButtonGroup from '../RadioButtonGroup'; +import RadioButtonGroup from './RadioButtonGroup'; import RadioButton from '../RadioButton'; describe('RadioButtonGroup', () => { @@ -60,8 +60,8 @@ describe('RadioButtonGroup', () => { }); describe('Component API', () => { - it('should support a custom className on the
', () => { - render( + it('should support a custom className on the outermost element', () => { + const { container } = render( { ); - const fieldset = screen - .getByText('test', { - selector: 'legend', - }) - .closest('fieldset'); - expect(fieldset).toHaveClass('custom-class'); + expect(container.firstChild).toHaveClass('custom-class'); }); it('should support passing in disabled to disable the
', () => { @@ -160,66 +155,47 @@ describe('RadioButtonGroup', () => { ); }); - describe('onChange event', () => { + it('should call `onChange` when the value of the group changes', () => { const onChange = jest.fn(); - const wrapper = mount( - - - + + render( + + + ); - const firstRadio = wrapper.find(RadioButton).first(); - const args = ['male', 'gender', { test: 'test event' }]; - - it('first child should not have checked set initially', () => { - expect(firstRadio.props().checked).toEqual(false); - }); - - it('invoking onChange sets checked on correct child', () => { - firstRadio.props().onChange(...args); - wrapper.update(); - expect(wrapper.find(RadioButton).first().props().checked).toEqual(true); - }); - - it('should invoke onChange with correct arguments', () => { - expect(onChange).toHaveBeenCalledWith(...args); - }); - - it('calling onChange with same args should not call onChange prop', () => { - onChange.mockClear(); - firstRadio.props().onChange(...args); - expect(onChange).not.toHaveBeenCalled(); - }); + userEvent.click(screen.getByLabelText('Option one')); + expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith( + 'option-one', + 'options', + expect.objectContaining({ + target: screen.getByLabelText('Option one'), + }) + ); }); describe('Getting derived state from props', () => { - const wrapper = shallow( - - - - - ); - - it('should initialize the current selection from props', () => { - expect(wrapper.state().selected).toEqual('male'); - }); - it('should change the current selection upon change in props', () => { - wrapper.setProps({ valueSelected: 'male' }); - wrapper.setState({ selected: 'male' }); - wrapper.setProps({ valueSelected: undefined }); - expect(wrapper.state().selected).toEqual('female'); - }); - - it('should avoid change the current selection upon setting props, unless there the value actually changes', () => { - wrapper.setProps({ valueSelected: 'female' }); - wrapper.setState({ selected: 'male' }); - wrapper.setProps({ valueSelected: 'female' }); - expect(wrapper.state().selected).toEqual('male'); + const { rerender } = render( + + + + + ); + + expect(screen.getByLabelText('Option one')).toBeChecked(); + + rerender( + + + + + ); + + expect(screen.getByLabelText('Option one')).not.toBeChecked(); + expect(screen.getByLabelText('Option two')).toBeChecked(); }); }); }); diff --git a/packages/react/src/components/RadioButtonGroup/RadioButtonGroup.js b/packages/react/src/components/RadioButtonGroup/RadioButtonGroup.js index aefed476a52e..b2340a8a284b 100644 --- a/packages/react/src/components/RadioButtonGroup/RadioButtonGroup.js +++ b/packages/react/src/components/RadioButtonGroup/RadioButtonGroup.js @@ -6,185 +6,134 @@ */ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; -import { warning } from '../../internal/warning'; import { Legend } from '../Text'; -import { FeatureFlagContext } from '../FeatureFlags'; -import { PrefixContext } from '../../internal/usePrefix'; - -export default class RadioButtonGroup extends React.Component { - static propTypes = { - /** - * Provide a collection of components to render in the group - */ - children: PropTypes.node, - - /** - * Provide an optional className to be applied to the container node - */ - className: PropTypes.string, - - /** - * Specify the to be selected by default - */ - defaultSelected: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - - /** - * Specify whether the group is disabled - */ - disabled: PropTypes.bool, - - /** - * Specify whether the legend should be hidden, or not - */ - hideLegend: PropTypes.bool, - - /** - * Provide where label text should be placed - */ - labelPosition: PropTypes.oneOf(['left', 'right']), - - /** - * Provide a legend to the RadioButtonGroup input that you are - * exposing to the user - */ - legendText: PropTypes.node, - - /** - * Specify the name of the underlying `` nodes - */ - name: PropTypes.string.isRequired, - - /** - * Provide an optional `onChange` hook that is called whenever the value of - * the group changes - */ - onChange: PropTypes.func, - - /** - * Provide where radio buttons should be placed - */ - orientation: PropTypes.oneOf(['horizontal', 'vertical']), - - /** - * Specify the value that is currently selected in the group - */ - valueSelected: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - }; - - static defaultProps = { - orientation: 'horizontal', - labelPosition: 'right', - hideLegend: false, - onChange: /* istanbul ignore next */ () => {}, - }; - - static contextType = FeatureFlagContext; - - static getDerivedStateFromProps({ valueSelected, defaultSelected }, state) { - const { prevValueSelected } = state; - return prevValueSelected === valueSelected - ? null - : { - selected: - typeof valueSelected !== 'undefined' - ? valueSelected - : defaultSelected, - prevValueSelected: valueSelected, - }; +import { usePrefix } from '../../internal/usePrefix'; + +const RadioButtonGroup = React.forwardRef(function RadioButtonGroup( + { + children, + className, + defaultSelected, + disabled, + labelPosition = 'right', + legendText, + name, + onChange = () => {}, + orientation = 'horizontal', + valueSelected, + }, + ref +) { + const prefix = usePrefix(); + + const [selected, setSelected] = useState(valueSelected ?? defaultSelected); + const [prevValueSelected, setPrevValueSelected] = useState(valueSelected); + + /** + * prop + state alignment - getDerivedStateFromProps + * only update if selected prop changes + */ + if (valueSelected !== prevValueSelected) { + setSelected(valueSelected); + setPrevValueSelected(valueSelected); } - state = { - selected: - typeof this.props.valueSelected !== 'undefined' - ? this.props.valueSelected - : this.props.defaultSelected, - }; - - getRadioButtons = () => { - const children = React.Children.map(this.props.children, (radioButton) => { + function getRadioButtons() { + const mappedChildren = React.Children.map(children, (radioButton) => { const { value } = radioButton.props; - /* istanbul ignore if */ - if (typeof radioButton.props.checked !== 'undefined') { - warning( - false, - `Instead of using the checked property on the RadioButton, set - the defaultSelected property or valueSelected property on the RadioButtonGroup.` - ); - } - return React.cloneElement(radioButton, { - name: this.props.name, + name: name, key: value, value: value, - onChange: this.handleChange, - checked: value === this.state.selected, + onChange: handleOnChange, + checked: value === selected, }); }); - return children; - }; + return mappedChildren; + } - handleChange = (newSelection, value, evt) => { - if (newSelection !== this.state.selected) { - this.setState({ selected: newSelection }); - this.props.onChange(newSelection, this.props.name, evt); + function handleOnChange(newSelection, value, evt) { + if (newSelection !== selected) { + setSelected(newSelection); + onChange(newSelection, name, evt); } - }; - - render() { - const { - disabled, - className, - hideLegend, - orientation, - labelPosition, - legendText, - } = this.props; - - const scope = this.context; - let enabled; - - if (scope.enabled) { - enabled = scope.enabled('enable-v11-release'); - } - - return ( - - {(prefix) => { - const wrapperClasses = classNames( - `${prefix}--radio-button-group`, - [enabled ? null : className], - { - [`${prefix}--radio-button-group--${orientation}`]: - orientation === 'vertical', - [`${prefix}--radio-button-group--label-${labelPosition}`]: - labelPosition, - } - ); - - const legendClasses = classNames(`${prefix}--label`, { - [`${prefix}--visually-hidden`]: hideLegend, - }); - - return ( -
-
- {legendText && ( - {legendText} - )} - {this.getRadioButtons()} -
-
- ); - }} -
- ); } -} + + const fieldsetClasses = classNames(`${prefix}--radio-button-group`, { + [`${prefix}--radio-button-group--${orientation}`]: + orientation === 'vertical', + [`${prefix}--radio-button-group--label-${labelPosition}`]: labelPosition, + }); + + const wrapperClasses = classNames(`${prefix}--form-item`, className); + + return ( +
+
+ {legendText && ( + {legendText} + )} + {getRadioButtons()} +
+
+ ); +}); + +RadioButtonGroup.propTypes = { + /** + * Provide a collection of `` components to render in the group + */ + children: PropTypes.node, + + /** + * Provide an optional className to be applied to the container node + */ + className: PropTypes.string, + + /** + * Specify the `` to be selected by default + */ + defaultSelected: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + + /** + * Specify whether the group is disabled + */ + disabled: PropTypes.bool, + + /** + * Provide where label text should be placed + */ + labelPosition: PropTypes.oneOf(['left', 'right']), + + /** + * Provide a legend to the RadioButtonGroup input that you are + * exposing to the user + */ + legendText: PropTypes.node, + + /** + * Specify the name of the underlying `` nodes + */ + name: PropTypes.string.isRequired, + + /** + * Provide an optional `onChange` hook that is called whenever the value of + * the group changes + */ + onChange: PropTypes.func, + + /** + * Provide where radio buttons should be placed + */ + orientation: PropTypes.oneOf(['horizontal', 'vertical']), + + /** + * Specify the value that is currently selected in the group + */ + valueSelected: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; + +export default RadioButtonGroup; diff --git a/packages/react/src/components/RadioButtonGroup/index.js b/packages/react/src/components/RadioButtonGroup/index.js index eb26247725f3..ae9ff9580d00 100644 --- a/packages/react/src/components/RadioButtonGroup/index.js +++ b/packages/react/src/components/RadioButtonGroup/index.js @@ -5,12 +5,6 @@ * LICENSE file in the root directory of this source tree. */ -import * as FeatureFlags from '@carbon/feature-flags'; -import { default as RadioButtonGroupNext } from './next/RadioButtonGroup'; -import { default as RadioButtonGroupClassic } from './RadioButtonGroup'; - -const RadioButtonGroup = FeatureFlags.enabled('enable-v11-release') - ? RadioButtonGroupNext - : RadioButtonGroupClassic; +import RadioButtonGroup from './RadioButtonGroup'; export default RadioButtonGroup; diff --git a/packages/react/src/components/RadioButtonGroup/next/RadioButtonGroup-test.js b/packages/react/src/components/RadioButtonGroup/next/RadioButtonGroup-test.js deleted file mode 100644 index 4c9a24ad6de8..000000000000 --- a/packages/react/src/components/RadioButtonGroup/next/RadioButtonGroup-test.js +++ /dev/null @@ -1,226 +0,0 @@ -/** - * 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 { render, screen } from '@testing-library/react'; -import React from 'react'; -import { shallow, mount } from 'enzyme'; -import RadioButtonGroup from '../RadioButtonGroup'; -import RadioButton from '../../RadioButton/next/RadioButton'; - -describe('RadioButtonGroup', () => { - it('should render `legendText` in a