diff --git a/.yarn/cache/postcss-loader-npm-7.0.0-e0a0c61fcd-b8e51e9989.zip b/.yarn/cache/postcss-loader-npm-7.0.0-e0a0c61fcd-b8e51e9989.zip new file mode 100644 index 000000000000..944aeea2dac3 Binary files /dev/null and b/.yarn/cache/postcss-loader-npm-7.0.0-e0a0c61fcd-b8e51e9989.zip differ diff --git a/e2e/components/Notifications/Notifications-test.e2e.js b/e2e/components/Notifications/Notifications-test.e2e.js index 5b12401a0328..595c41bec074 100644 --- a/e2e/components/Notifications/Notifications-test.e2e.js +++ b/e2e/components/Notifications/Notifications-test.e2e.js @@ -16,24 +16,24 @@ test.describe('Notifications', () => { test.describe(theme, () => { test('toast @vrt', async ({ page }) => { await snapshotStory(page, { - component: 'Notifications', - id: 'components-notifications--toast', + component: 'Toast', + id: 'components-notifications-toast--default', theme, }); }); test('inline @vrt', async ({ page }) => { await snapshotStory(page, { - component: 'Notifications', - id: 'components-notifications--inline', + component: 'Inline', + id: 'components-notifications-inline--default', theme, }); }); test('actionable @vrt', async ({ page }) => { await snapshotStory(page, { - component: 'Notifications', - id: 'components-notifications--actionable', + component: 'Actionable', + id: 'components-notifications-actionable--default', theme, }); }); @@ -42,8 +42,8 @@ test.describe('Notifications', () => { test('accessibility-checker @avt', async ({ page }) => { await visitStory(page, { - component: 'Notifications', - id: 'components-notifications--toast', + component: 'Toast', + id: 'components-notifications-toast--default', globals: { theme: 'white', }, diff --git a/packages/grid/scss/_css-grid.scss b/packages/grid/scss/_css-grid.scss index bfb584b61ecd..471b4903ba20 100644 --- a/packages/grid/scss/_css-grid.scss +++ b/packages/grid/scss/_css-grid.scss @@ -247,21 +247,21 @@ $span: $columns * 0.75; --cds-grid-columns: #{$span}; - grid-column: span #{$span} / span #{$span}; + grid-column: span list.slash($span, span) #{$span}; } .#{$prefix}--#{$name}\:col-span-50 { $span: $columns * 0.5; --cds-grid-columns: #{$span}; - grid-column: span #{$span} / span #{$span}; + grid-column: span list.slash($span, span) #{$span}; } .#{$prefix}--#{$name}\:col-span-25 { $span: $columns * 0.25; --cds-grid-columns: #{$span}; - grid-column: span #{$span} / span #{$span}; + grid-column: span list.slash($span, span) #{$span}; } } @else { @include breakpoint($name) { @@ -279,21 +279,21 @@ $span: $columns * 0.75; --cds-grid-columns: #{$span}; - grid-column: span #{$span} / span #{$span}; + grid-column: span list.slash($span, span) #{$span}; } .#{$prefix}--#{$name}\:col-span-50 { $span: $columns * 0.5; --cds-grid-columns: #{$span}; - grid-column: span #{$span} / span #{$span}; + grid-column: span list.slash($span, span) #{$span}; } .#{$prefix}--#{$name}\:col-span-25 { $span: $columns * 0.25; --cds-grid-columns: #{$span}; - grid-column: span #{$span} / span #{$span}; + grid-column: span list.slash($span, span) #{$span}; } } } @@ -426,7 +426,7 @@ @if is-smallest-breakpoint($key, $breakpoints) { --cds-grid-columns: #{$span}; - grid-column: span #{$span} / span #{$span}; + grid-column: span list.slash($span, span) #{$span}; } @else { $previous-breakpoint: breakpoint-prev($key, $breakpoints); $previous-column-count: get-column-count( @@ -439,7 +439,7 @@ @include breakpoint($key) { --cds-grid-columns: #{$span}; - grid-column: span #{$span} / span #{$span}; + grid-column: span list.slash($span, span) #{$span}; } } } diff --git a/packages/grid/scss/_flex-grid.scss b/packages/grid/scss/_flex-grid.scss index f9230c4a60bf..085c70f29d0f 100644 --- a/packages/grid/scss/_flex-grid.scss +++ b/packages/grid/scss/_flex-grid.scss @@ -70,13 +70,8 @@ // Add a `max-width` to ensure content within each column does not blow out // the width of the column. Applies to IE10+ and Firefox. Chrome and Safari // do not appear to require this. - @if meta.function-exists('div', 'math') { - max-width: math.percentage(math.div($span, $columns)); - flex: 0 0 math.percentage(math.div($span, $columns)); - } @else { - max-width: math.percentage(($span / $columns)); - flex: 0 0 math.percentage(($span / $columns)); - } + max-width: math.percentage(math.div($span, $columns)); + flex: 0 0 math.percentage(math.div($span, $columns)); } } @@ -87,11 +82,7 @@ /// @group @carbon/grid @mixin -make-col-offset($span, $columns) { $offset: 0; - @if meta.function-exists('div', 'math') { - $offset: math.div($span, $columns); - } @else { - $offset: ($span / $columns); - } + $offset: math.div($span, $columns); @if $offset == 0 { margin-left: 0; } @else { diff --git a/packages/grid/scss/_mixins.scss b/packages/grid/scss/_mixins.scss index d507e20df5c2..3ff2861dd3b4 100644 --- a/packages/grid/scss/_mixins.scss +++ b/packages/grid/scss/_mixins.scss @@ -64,13 +64,8 @@ // Add a `max-width` to ensure content within each column does not blow out // the width of the column. Applies to IE10+ and Firefox. Chrome and Safari // do not appear to require this. - @if meta.function-exists('div', 'math') { - max-width: math.percentage(math.div($span, $columns)); - flex: 0 0 math.percentage(math.div($span, $columns)); - } @else { - max-width: math.percentage(($span / $columns)); - flex: 0 0 math.percentage(($span / $columns)); - } + max-width: math.percentage(math.div($span, $columns)); + flex: 0 0 math.percentage(math.div($span, $columns)); } } @@ -81,11 +76,7 @@ /// @group @carbon/grid @mixin -make-col-offset($span, $columns) { $offset: 0; - @if meta.function-exists('div', 'math') { - $offset: math.div($span, $columns); - } @else { - $offset: ($span / $columns); - } + $offset: math.div($span, $columns); @if $offset == 0 { margin-left: 0; } @else { diff --git a/packages/layout/scss/_convert.import.scss b/packages/layout/scss/_convert.import.scss deleted file mode 100644 index 3ddfe00c476c..000000000000 --- a/packages/layout/scss/_convert.import.scss +++ /dev/null @@ -1,63 +0,0 @@ -// -// Copyright IBM Corp. 2018, 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. -// -//------------------------------------------- -// Compatibility notes (*.import.scss) -// ------------------------------------------ -// -// This file is intended to be consumed and processed with dart-sass. -// It is incompatible with node-sass/libsass as it contains sass language features -// or functions that are unavailable in node-sass/libsass, such as `math.div`. -// -// The non-`.import` suffixed version of this file eg. `_filename.scss` -// is intended to be compatible with node-sass/libsass. -// -// Styles authored within this file must be duplicated to the corresponding -// compatibility file to ensure we continue to support node-sass and dart-sass -// in v10. - -@use 'sass:meta'; -@use 'sass:math'; - -/// Default font size -/// @type Number -/// @access public -/// @group @carbon/layout -$carbon--base-font-size: 16px !default; - -/// Convert a given px unit to a rem unit -/// @param {Number} $px - Number with px unit -/// @return {Number} Number with rem unit -/// @access public -/// @group @carbon/layout -@function carbon--rem($px) { - @if unit($px) != 'px' { - @error "Expected argument $px to be of type `px`, instead received: `#{unit($px)}`"; - } - - @if meta.function-exists('div', 'math') { - @return math.div($px, $carbon--base-font-size) * 1rem; - } @else { - @return ($px / $carbon--base-font-size) * 1rem; - } -} - -/// Convert a given px unit to a em unit -/// @param {Number} $px - Number with px unit -/// @return {Number} Number with em unit -/// @access public -/// @group @carbon/layout -@function carbon--em($px) { - @if unit($px) != 'px' { - @error "Expected argument $px to be of type `px`, instead received: `#{unit($px)}`"; - } - - @if meta.function-exists('div', 'math') { - @return math.div($px, $carbon--base-font-size) * 1em; - } @else { - @return ($px / $carbon--base-font-size) * 1em; - } -} diff --git a/packages/layout/scss/_convert.scss b/packages/layout/scss/_convert.scss index c9e5313ffe94..40a24408f582 100644 --- a/packages/layout/scss/_convert.scss +++ b/packages/layout/scss/_convert.scss @@ -24,11 +24,7 @@ $base-font-size: 16px !default; @error "Expected argument $px to be of type `px`, instead received: `#{unit($px)}`"; } - @if meta.function-exists('div', 'math') { - @return math.div($px, $base-font-size) * 1rem; - } @else { - @return ($px / $base-font-size) * 1rem; - } + @return math.div($px, $base-font-size) * 1rem; } /// Convert a given px unit to a em unit @@ -41,9 +37,5 @@ $base-font-size: 16px !default; @error "Expected argument $px to be of type `px`, instead received: `#{unit($px)}`"; } - @if meta.function-exists('div', 'math') { - @return math.div($px, $base-font-size) * 1em; - } @else { - @return ($px / $base-font-size) * 1em; - } + @return math.div($px, $base-font-size) * 1em; } diff --git a/packages/layout/scss/modules/_convert.scss b/packages/layout/scss/modules/_convert.scss index c9e5313ffe94..40a24408f582 100644 --- a/packages/layout/scss/modules/_convert.scss +++ b/packages/layout/scss/modules/_convert.scss @@ -24,11 +24,7 @@ $base-font-size: 16px !default; @error "Expected argument $px to be of type `px`, instead received: `#{unit($px)}`"; } - @if meta.function-exists('div', 'math') { - @return math.div($px, $base-font-size) * 1rem; - } @else { - @return ($px / $base-font-size) * 1rem; - } + @return math.div($px, $base-font-size) * 1rem; } /// Convert a given px unit to a em unit @@ -41,9 +37,5 @@ $base-font-size: 16px !default; @error "Expected argument $px to be of type `px`, instead received: `#{unit($px)}`"; } - @if meta.function-exists('div', 'math') { - @return math.div($px, $base-font-size) * 1em; - } @else { - @return ($px / $base-font-size) * 1em; - } + @return math.div($px, $base-font-size) * 1em; } diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index f983c2520855..dc154b6235a1 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -91,8 +91,83 @@ Map { }, }, "ActionableNotification" => Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], + "defaultProps": Object { + "closeOnEscape": true, + "hasFocus": true, + "hideCloseButton": false, + "inline": false, + "kind": "error", + "onCloseButtonClick": [Function], + "role": "alertdialog", + }, + "propTypes": Object { + "actionButtonLabel": Object { + "isRequired": true, + "type": "string", + }, + "ariaLabel": Object { + "type": "string", + }, + "caption": Object { + "type": "string", + }, + "children": Object { + "type": "node", + }, + "className": Object { + "type": "string", + }, + "closeOnEscape": Object { + "type": "bool", + }, + "hasFocus": Object { + "type": "bool", + }, + "hideCloseButton": Object { + "type": "bool", + }, + "inline": Object { + "type": "bool", + }, + "kind": Object { + "args": Array [ + Array [ + "error", + "info", + "info-square", + "success", + "warning", + "warning-alt", + ], + ], + "isRequired": true, + "type": "oneOf", + }, + "lowContrast": Object { + "type": "bool", + }, + "onActionButtonClick": Object { + "type": "func", + }, + "onClose": Object { + "type": "func", + }, + "onCloseButtonClick": Object { + "type": "func", + }, + "role": Object { + "type": "string", + }, + "statusIconDescription": Object { + "type": "string", + }, + "subtitle": Object { + "type": "string", + }, + "title": Object { + "type": "string", + }, + }, }, "AspectRatio" => Object { "propTypes": Object { @@ -1154,9 +1229,7 @@ Map { "render": [Function], }, "ComposedModal" => Object { - "contextType": Object { - "$$typeof": Symbol(react.context), - }, + "$$typeof": Symbol(react.forward_ref), "defaultProps": Object { "onKeyDown": [Function], "selectorPrimaryFocus": "[data-modal-primary-focus]", @@ -1196,12 +1269,7 @@ Map { "type": "string", }, "selectorsFloatingMenus": Object { - "args": Array [ - Object { - "type": "string", - }, - ], - "type": "arrayOf", + "type": "string", }, "size": Object { "args": Array [ @@ -1215,6 +1283,7 @@ Map { "type": "oneOf", }, }, + "render": [Function], }, "Content" => Object { "defaultProps": Object { @@ -3694,8 +3763,64 @@ Map { }, }, "InlineNotification" => Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], + "defaultProps": Object { + "hideCloseButton": false, + "kind": "error", + "onCloseButtonClick": [Function], + "role": "status", + }, + "propTypes": Object { + "children": Object { + "type": "node", + }, + "className": Object { + "type": "string", + }, + "hideCloseButton": Object { + "type": "bool", + }, + "kind": Object { + "args": Array [ + Array [ + "error", + "info", + "info-square", + "success", + "warning", + "warning-alt", + ], + ], + "type": "oneOf", + }, + "lowContrast": Object { + "type": "bool", + }, + "onClose": Object { + "type": "func", + }, + "onCloseButtonClick": Object { + "type": "func", + }, + "role": Object { + "args": Array [ + Array [ + "alert", + "log", + "status", + ], + ], + "type": "oneOf", + }, + "statusIconDescription": Object { + "type": "string", + }, + "subtitle": Object { + "type": "string", + }, + "title": Object { + "type": "string", + }, + }, }, "Layer" => Object { "propTypes": Object { @@ -4157,9 +4282,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 +4485,6 @@ Map { "type": "bool", }, "placeholder": Object { - "isRequired": true, "type": "string", }, "selectionFeedback": Object { @@ -4402,6 +4524,7 @@ Map { "type": "node", }, }, + "render": [Function], }, "defaultProps": Object { "clearSelectionDescription": "Total items selected: ", @@ -4666,16 +4789,83 @@ Map { "render": [Function], }, "NotificationActionButton" => Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], + "propTypes": Object { + "children": Object { + "type": "node", + }, + "className": Object { + "type": "string", + }, + "inline": Object { + "type": "bool", + }, + "onClick": Object { + "type": "func", + }, + }, }, "NotificationButton" => Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], - }, - "NotificationTextDetails" => Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], + "defaultProps": Object { + "ariaLabel": "close notification", + "notificationType": "toast", + "renderIcon": Object { + "$$typeof": Symbol(react.forward_ref), + "propTypes": Object { + "size": Object { + "args": Array [ + Array [ + Object { + "type": "number", + }, + Object { + "type": "string", + }, + ], + ], + "type": "oneOfType", + }, + }, + "render": [Function], + }, + "type": "button", + }, + "propTypes": Object { + "ariaLabel": Object { + "type": "string", + }, + "className": Object { + "type": "string", + }, + "name": Object { + "type": "string", + }, + "notificationType": Object { + "args": Array [ + Array [ + "toast", + "inline", + "actionable", + ], + ], + "type": "oneOf", + }, + "renderIcon": Object { + "args": Array [ + Array [ + Object { + "type": "func", + }, + Object { + "type": "object", + }, + ], + ], + "type": "oneOfType", + }, + "type": Object { + "type": "string", + }, + }, }, "NumberInput" => Object { "$$typeof": Symbol(react.forward_ref), @@ -5326,7 +5516,6 @@ Map { }, ], ], - "isRequired": true, "type": "oneOfType", }, }, @@ -7827,8 +8016,74 @@ Map { "render": [Function], }, "ToastNotification" => Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], + "defaultProps": Object { + "hideCloseButton": false, + "kind": "error", + "onCloseButtonClick": [Function], + "role": "status", + "timeout": 0, + }, + "propTypes": Object { + "ariaLabel": Object { + "type": "string", + }, + "caption": Object { + "type": "string", + }, + "children": Object { + "type": "node", + }, + "className": Object { + "type": "string", + }, + "hideCloseButton": Object { + "type": "bool", + }, + "kind": Object { + "args": Array [ + Array [ + "error", + "info", + "info-square", + "success", + "warning", + "warning-alt", + ], + ], + "type": "oneOf", + }, + "lowContrast": Object { + "type": "bool", + }, + "onClose": Object { + "type": "func", + }, + "onCloseButtonClick": Object { + "type": "func", + }, + "role": Object { + "args": Array [ + Array [ + "alert", + "log", + "status", + ], + ], + "type": "oneOf", + }, + "statusIconDescription": Object { + "type": "string", + }, + "subtitle": Object { + "type": "string", + }, + "timeout": Object { + "type": "number", + }, + "title": Object { + "type": "string", + }, + }, }, "Toggle" => Object { "propTypes": Object { diff --git a/packages/react/package.json b/packages/react/package.json index 45942119174a..bba6d02578d9 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -100,7 +100,7 @@ "html-webpack-plugin": "^5.5.0", "mini-css-extract-plugin": "^2.4.5", "postcss": "^8.4.5", - "postcss-loader": "^6.2.1", + "postcss-loader": "^7.0.0", "process": "^0.11.10", "prop-types": "^15.7.2", "react": "^17.0.2", diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js index 7bd977da856a..48fc1f658f1d 100644 --- a/packages/react/src/__tests__/index-test.js +++ b/packages/react/src/__tests__/index-test.js @@ -99,7 +99,6 @@ describe('Carbon Components React', () => { "MultiSelect", "NotificationActionButton", "NotificationButton", - "NotificationTextDetails", "NumberInput", "NumberInputSkeleton", "OrderedList", diff --git a/packages/react/src/components/ComposedModal/ComposedModal-test.js b/packages/react/src/components/ComposedModal/ComposedModal-test.js index 3881d8cbbbcf..cf0343c1a053 100644 --- a/packages/react/src/components/ComposedModal/ComposedModal-test.js +++ b/packages/react/src/components/ComposedModal/ComposedModal-test.js @@ -8,7 +8,8 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import Button from '../Button'; -import ComposedModal, { +import { + ComposedModal, ModalHeader, ModalBody, ModalFooter, diff --git a/packages/react/src/components/ComposedModal/index.js b/packages/react/src/components/ComposedModal/index.js index ab0019e7c102..1b483e9f4773 100644 --- a/packages/react/src/components/ComposedModal/index.js +++ b/packages/react/src/components/ComposedModal/index.js @@ -29,4 +29,3 @@ export const ComposedModal = FeatureFlags.enabled('enable-v11-release') : ComposedModalClassic; export { ModalBody }; -export default from './ComposedModal'; 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/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`] = ` - - - -
- -
-
- - - - -
-
-
-
-
-
-
-`; diff --git a/packages/react/src/components/Notification/Notification-story.js b/packages/react/src/components/Notification/Notification-story.js deleted file mode 100644 index dfda68894760..000000000000 --- a/packages/react/src/components/Notification/Notification-story.js +++ /dev/null @@ -1,93 +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, - number, - select, - text, -} from '@storybook/addon-knobs'; -import { - ToastNotification, - InlineNotification, - NotificationActionButton, -} from './Notification'; -import mdx from './Notification.mdx'; - -const kinds = { - 'Error (error)': 'error', - 'Info (info)': 'info', - 'Info square (info-square)': 'info-square', - 'Success (success)': 'success', - 'Warning (warning)': 'warning', - 'Warning (warning-alt)': 'warning-alt', -}; -const notificationProps = () => ({ - kind: select('The notification kind (kind)', kinds, 'info'), - lowContrast: boolean('Use low contrast variant (lowContrast)', false), - role: text('ARIA role (role)', 'alert'), - title: text('Title (title)', 'Notification title'), - subtitle: text('Subtitle (subtitle)', 'Subtitle text goes here.'), - iconDescription: text( - 'Icon description (iconDescription)', - 'describes the close button' - ), - statusIconDescription: text( - 'Status icon description (statusIconDescription)', - 'describes the status icon' - ), - hideCloseButton: boolean('Hide close button (hideCloseButton)', false), - onClose: action('onClose'), - onCloseButtonClick: action('onCloseButtonClick'), -}); - -const toastNotificationProps = () => ({ - ...notificationProps(), - timeout: number( - 'Duration in milliseconds to display notification (timeout)', - 0 - ), -}); - -export default { - title: 'Components/Notifications', - component: ToastNotification, - decorators: [withKnobs], - subcomponents: { - InlineNotification, - }, - parameters: { - docs: { - page: mdx, - }, - }, -}; - -export const Toast = () => ( - -); - -export const Inline = () => ( - - {text('Action (NotificationActionButton > children)', 'Action')} - - } - /> -); - -Inline.storyName = 'inline'; diff --git a/packages/react/src/components/Notification/Notification-test.js b/packages/react/src/components/Notification/Notification-test.js index 85b8f1c6a5e3..e09faebde6e1 100644 --- a/packages/react/src/components/Notification/Notification-test.js +++ b/packages/react/src/components/Notification/Notification-test.js @@ -6,356 +6,337 @@ */ import React from 'react'; -import { Close, ErrorFilled, CheckmarkFilled } from '@carbon/icons-react'; +import { ErrorFilled } from '@carbon/icons-react'; import { NotificationButton, - NotificationTextDetails, ToastNotification, InlineNotification, + ActionableNotification, } from './Notification'; -import { shallow, mount } from 'enzyme'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { it } from 'window-or-global'; const prefix = 'cds'; describe('NotificationButton', () => { - describe('Renders as expected', () => { - const wrapper = shallow(); - - it('renders given className', () => { - expect(wrapper.hasClass('some-class')).toBe(true); - }); - - it('renders only one Icon', () => { - const icon = wrapper.find(Close); - expect(icon.length).toEqual(1); - }); + it('should place the `className` prop on the outermost DOM node', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('test'); + }); - it('supports custom icon', () => { - const iconButton = mount( - } - /> - ); - const originalIcon = mount().find('svg'); - const icon = iconButton.find('svg'); - expect(icon.find(':not(svg):not(title)').html()).toBe( - originalIcon.children().html() - ); - }); + it('supports custom icon', () => { + const { rerender } = render(); + const defaultIcon = screen.queryByRole('button').innerHTML; - describe('When notificationType equals "toast"', () => { - it('button should have correct className by default', () => { - expect( - wrapper.hasClass(`${prefix}--toast-notification__close-button`) - ).toBe(true); - }); - - it('icon should have correct className by default', () => { - const icon = wrapper.find(Close); - expect(icon.hasClass(`${prefix}--toast-notification__close-icon`)).toBe( - true - ); - }); - }); + rerender( + } + /> + ); + const customIcon = screen.queryByRole('button').innerHTML; - describe('When notificationType equals "inline"', () => { - it('button should have correct className', () => { - wrapper.setProps({ notificationType: 'inline' }); - expect( - wrapper.hasClass(`${prefix}--inline-notification__close-button`) - ).toBe(true); - }); - - it('icon should have correct className', () => { - const icon = wrapper.find(Close); - expect( - icon.hasClass(`${prefix}--inline-notification__close-icon`) - ).toBe(true); - }); - }); + expect(defaultIcon).not.toEqual(customIcon); }); -}); - -describe('NotificationTextDetails', () => { - describe('Renders as expected', () => { - const wrapper = shallow(); - describe('When notificationType equals "toast"', () => { - it('div should have correct className by default', () => { - expect(wrapper.hasClass(`${prefix}--toast-notification__details`)).toBe( - true - ); - }); - }); + it('interpolates matching className based on notificationType prop', () => { + const { rerender, container } = render(); + const notificationTypes = ['toast', 'inline']; - describe('When notificationType equals "inline"', () => { - it('div should have correct className', () => { - wrapper.setProps({ notificationType: 'inline' }); - expect( - wrapper.hasClass(`${prefix}--inline-notification__text-wrapper`) - ).toBe(true); - }); + notificationTypes.forEach((notificationType) => { + rerender(); + expect(container.firstChild).toHaveClass( + `${prefix}--${notificationType}-notification__close-button` + ); + expect(screen.queryByRole('button').firstChild).toHaveClass( + `${prefix}--${notificationType}-notification__close-icon` + ); }); }); }); describe('ToastNotification', () => { - describe('Renders as expected', () => { - const toast = shallow( - + it('should have role=status by default', () => { + const { container } = render( + ); - it('renders itself', () => { - expect(toast.length).toEqual(1); - }); + expect(container.firstChild).toHaveAttribute('role', 'status'); + }); - it('renders HTML for toast notifications when caption exists', () => { - expect(toast.hasClass(`${prefix}--toast-notification`)).toBe(true); + it('should place the `className` prop on the outermost DOM node', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass('test'); + }); + + it('interpolates matching className based on kind prop', () => { + const { rerender } = render( + + ); + const kinds = [ + 'error', + 'info', + 'info-square', + 'success', + 'warning', + 'warning-alt', + ]; + kinds.forEach((kind) => { + rerender(); + expect(screen.queryByRole('status')).toHaveClass( + `${prefix}--toast-notification--${kind}` + ); }); + }); - it('adds extra classes via className', () => { - toast.setProps({ className: 'extra-class' }); + it('allows non-interactive elements as children', () => { + render( + +

Sample text

+
+ ); + expect(screen.queryByText(/Sample text/i)).toBeInTheDocument(); + }); - expect(toast.hasClass('extra-class')).toBe(true); - }); + it('does not allow interactive elements as children', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - it('interpolates matching className based on kind prop', () => { - const kinds = ['error', 'info', 'success', 'warning']; + expect(() => { + render( + + + + ); + }).toThrow(); - kinds.forEach((kind) => { - toast.setProps({ kind }); - expect( - toast.hasClass(`${prefix}--toast-notification--${kind}`) - ).toEqual(true); - }); - }); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); - it('has [role="alert"] on wrapping
', () => { - expect(toast.props().role).toEqual('alert'); - }); + it('close button is rendered by default and includes aria-hidden=true', () => { + render(); - it('sets a new kind when passed in via props', () => { - toast.setProps({ kind: 'success' }); - expect(toast.props().kind).toEqual('success'); + const closeButton = screen.queryByRole('button', { + hidden: true, }); - it('can render any node for the subtitle and caption', () => { - toast.setProps({ - subtitle: + ); - }); + }).toThrow(); - it('adds extra classes via className', () => { - inline.setProps({ className: 'extra-class' }); - expect(inline.find('.extra-class').exists()).toBe(true); - }); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); - it('interpolates matching className based on kind prop', () => { - const kinds = ['error', 'info', 'success', 'warning']; + it('close button is rendered by default and includes aria-hidden=true', () => { + render(); - kinds.forEach((kind) => { - inline.setProps({ kind }); - expect( - inline.find(`.${prefix}--inline-notification--${kind}`).exists() - ).toEqual(true); - }); + const closeButton = screen.queryByRole('button', { + hidden: true, }); + expect(closeButton).toBeInTheDocument(); + expect(closeButton).toHaveAttribute('aria-hidden', 'true'); + }); - it('has [role="alert"] on wrapping
', () => { - expect(inline.props().role).toEqual('alert'); + it('does not render close button when `hideCloseButton` is provided', () => { + render(); + const closeButton = screen.queryByRole('button', { + hidden: true, }); + expect(closeButton).not.toBeInTheDocument(); + }); - it('sets a new kind when passed in via props', () => { - inline.setProps({ kind: 'success' }); - expect(inline.props().kind).toEqual('success'); - }); + it('calls `onClose` when notification is closed', async () => { + const onClose = jest.fn(); + render(); - it('can render any node for the subtitle', () => { - inline.setProps({ subtitle: @@ -55,6 +83,11 @@ NotificationActionButton.propTypes = { */ className: PropTypes.string, + /** + * Specify if the visual treatment of the button should be for an inline notification + */ + inline: PropTypes.bool, + /** * Optionally specify a click handler for the notification action button. */ @@ -64,7 +97,6 @@ NotificationActionButton.propTypes = { export function NotificationButton({ ariaLabel, className, - iconDescription, type, renderIcon: IconTag, name, @@ -86,12 +118,10 @@ export function NotificationButton({ {...rest} // eslint-disable-next-line react/button-has-type type={type} - aria-label={iconDescription} - title={iconDescription} + aria-label={ariaLabel} + title={ariaLabel} className={buttonClassName}> - {IconTag && ( - - )} + {IconTag && } ); } @@ -107,11 +137,6 @@ NotificationButton.propTypes = { */ className: PropTypes.string, - /** - * Provide a description for "close" icon that can be read by screen readers - */ - iconDescription: PropTypes.string, - /** * Specify an optional icon for the Button through a string, * if something but regular "close" icon is desirable @@ -121,7 +146,7 @@ NotificationButton.propTypes = { /** * Specify the notification type */ - notificationType: PropTypes.oneOf(['toast', 'inline']), + notificationType: PropTypes.oneOf(['toast', 'inline', 'actionable']), /** * Optional prop to allow overriding the icon rendering. @@ -136,80 +161,12 @@ NotificationButton.propTypes = { }; NotificationButton.defaultProps = { - ariaLabel: 'close notification', // TODO: deprecate this prop + ariaLabel: 'close notification', notificationType: 'toast', type: 'button', - iconDescription: 'close icon', renderIcon: Close, }; -export function NotificationTextDetails({ - title, - subtitle, - caption, - notificationType, - children, - ...rest -}) { - const prefix = usePrefix(); - if (notificationType === 'toast') { - return ( -
-

{title}

-
- {subtitle} -
- {caption && ( -
- {caption} -
- )} - {children} -
- ); - } - - if (notificationType === 'inline') { - return ( -
-

{title}

-
- {subtitle} -
- {children} -
- ); - } -} - -NotificationTextDetails.propTypes = { - /** - * Specify the caption - */ - caption: PropTypes.node, - /** - * Pass in the children that will be rendered in NotificationTextDetails - */ - children: PropTypes.node, - /** - * Specify the notification type - */ - notificationType: PropTypes.oneOf(['toast', 'inline']), - /** - * Specify the sub-title - */ - subtitle: PropTypes.node, - /** - * Specify the title - */ - title: PropTypes.string, -}; - -NotificationTextDetails.defaultProps = { - title: 'title', - notificationType: 'toast', -}; - const iconTypes = { error: ErrorFilled, success: CheckmarkFilled, @@ -248,35 +205,37 @@ NotificationIcon.propTypes = { export function ToastNotification({ role, - notificationType, onClose, onCloseButtonClick, - iconDescription, statusIconDescription, className, - caption, - subtitle, - title, + children, kind, lowContrast, hideCloseButton, - children, timeout, + title, + caption, + subtitle, ...rest }) { - const prefix = usePrefix(); const [isOpen, setIsOpen] = useState(true); + const prefix = usePrefix(); const containerClassName = cx(className, { [`${prefix}--toast-notification`]: true, [`${prefix}--toast-notification--low-contrast`]: lowContrast, [`${prefix}--toast-notification--${kind}`]: kind, }); + const contentRef = useRef(null); + useNoInteractiveChildren(contentRef); + const handleClose = (evt) => { if (!onClose || onClose(evt) !== false) { setIsOpen(false); } }; + const ref = useRef(null); function handleCloseButtonClick(event) { onCloseButtonClick(event); @@ -310,24 +269,36 @@ export function ToastNotification({ } return ( -
+
- +
+ {title && ( +
{title}
+ )} + {subtitle && ( +
+ {subtitle} +
+ )} + {caption && ( +
+ {caption} +
+ )} {children} - +
{!hideCloseButton && (
@@ -335,13 +306,18 @@ export function ToastNotification({ } ToastNotification.propTypes = { + /** + * Provide a description for "close" icon button that can be read by screen readers + */ + ariaLabel: PropTypes.string, + /** * Specify the caption */ - caption: PropTypes.node, + caption: PropTypes.string, /** - * Pass in the children that will be rendered within the ToastNotification + * Specify the content */ children: PropTypes.node, @@ -355,11 +331,6 @@ ToastNotification.propTypes = { */ hideCloseButton: PropTypes.bool, - /** - * Provide a description for "close" icon that can be read by screen readers - */ - iconDescription: PropTypes.string, - /** * Specify what state the notification represents */ @@ -370,19 +341,13 @@ ToastNotification.propTypes = { 'success', 'warning', 'warning-alt', - ]).isRequired, + ]), /** * Specify whether you are using the low contrast variant of the ToastNotification. */ lowContrast: PropTypes.bool, - /** - * By default, this value is "toast". You can also provide an alternate type - * if it makes sense for the underlying `` and `` - */ - notificationType: PropTypes.string, - /** * Provide a function that is called when menu is closed */ @@ -394,10 +359,10 @@ ToastNotification.propTypes = { onCloseButtonClick: PropTypes.func, /** - * By default, this value is "alert". You can also provide an alternate + * By default, this value is "status". You can also provide an alternate * role if it makes sense from the accessibility-side */ - role: PropTypes.string.isRequired, + role: PropTypes.oneOf(['alert', 'log', 'status']), /** * Provide a description for "status" icon that can be read by screen readers @@ -407,7 +372,7 @@ ToastNotification.propTypes = { /** * Specify the sub-title */ - subtitle: PropTypes.node, + subtitle: PropTypes.string, /** * Specify an optional duration the notification should be closed in @@ -417,39 +382,33 @@ ToastNotification.propTypes = { /** * Specify the title */ - title: PropTypes.string.isRequired, + title: PropTypes.string, }; ToastNotification.defaultProps = { kind: 'error', - title: 'provide a title', - role: 'alert', - notificationType: 'toast', - iconDescription: 'closes notification', + role: 'status', onCloseButtonClick: () => {}, hideCloseButton: false, timeout: 0, }; export function InlineNotification({ - actions, + children, + title, + subtitle, role, - notificationType, onClose, onCloseButtonClick, - iconDescription, statusIconDescription, className, - subtitle, - title, kind, lowContrast, hideCloseButton, - children, ...rest }) { - const prefix = usePrefix(); const [isOpen, setIsOpen] = useState(true); + const prefix = usePrefix(); const containerClassName = cx(className, { [`${prefix}--inline-notification`]: true, [`${prefix}--inline-notification--low-contrast`]: lowContrast, @@ -457,11 +416,15 @@ export function InlineNotification({ [`${prefix}--inline-notification--hide-close-button`]: hideCloseButton, }); + const contentRef = useRef(null); + useNoInteractiveChildren(contentRef); + const handleClose = (evt) => { if (!onClose || onClose(evt) !== false) { setIsOpen(false); } }; + const ref = useRef(null); function handleCloseButtonClick(event) { onCloseButtonClick(event); @@ -473,26 +436,35 @@ export function InlineNotification({ } return ( -
+
- +
+ {title && ( +
+ {title} +
+ )} + {subtitle && ( +
+ {subtitle} +
+ )} {children} - +
- {actions} {!hideCloseButton && (
@@ -501,12 +473,187 @@ export function InlineNotification({ InlineNotification.propTypes = { /** - * Pass in the action nodes that will be rendered within the InlineNotification + * Specify the content + */ + children: PropTypes.node, + + /** + * Specify an optional className to be applied to the notification box + */ + className: PropTypes.string, + + /** + * Specify the close button should be disabled, or not + */ + hideCloseButton: PropTypes.bool, + + /** + * Specify what state the notification represents + */ + kind: PropTypes.oneOf([ + 'error', + 'info', + 'info-square', + 'success', + 'warning', + 'warning-alt', + ]), + + /** + * Specify whether you are using the low contrast variant of the InlineNotification. + */ + lowContrast: PropTypes.bool, + + /** + * Provide a function that is called when menu is closed + */ + onClose: PropTypes.func, + + /** + * Provide a function that is called when the close button is clicked + */ + onCloseButtonClick: PropTypes.func, + + /** + * By default, this value is "status". You can also provide an alternate + * role if it makes sense from the accessibility-side. + */ + role: PropTypes.oneOf(['alert', 'log', 'status']), + + /** + * Provide a description for "status" icon that can be read by screen readers + */ + statusIconDescription: PropTypes.string, + + /** + * Specify the sub-title + */ + subtitle: PropTypes.string, + + /** + * Specify the title + */ + title: PropTypes.string, +}; + +InlineNotification.defaultProps = { + kind: 'error', + role: 'status', + onCloseButtonClick: () => {}, + hideCloseButton: false, +}; + +export function ActionableNotification({ + actionButtonLabel, + ariaLabel, + children, + role, + onActionButtonClick, + onClose, + onCloseButtonClick, + statusIconDescription, + className, + inline, + kind, + lowContrast, + hideCloseButton, + hasFocus, + closeOnEscape, + title, + subtitle, + ...rest +}) { + const [isOpen, setIsOpen] = useState(true); + const prefix = usePrefix(); + const containerClassName = cx(className, { + [`${prefix}--actionable-notification`]: true, + [`${prefix}--actionable-notification--toast`]: !inline, + [`${prefix}--actionable-notification--low-contrast`]: lowContrast, + [`${prefix}--actionable-notification--${kind}`]: kind, + [`${prefix}--actionable-notification--hide-close-button`]: hideCloseButton, + }); + + const ref = useRef(null); + useIsomorphicEffect(() => { + if (ref.current && hasFocus) { + ref.current.focus(); + } + }); + + const handleClose = (evt) => { + if (!onClose || onClose(evt) !== false) { + setIsOpen(false); + } + }; + useEscapeToClose(ref, handleCloseButtonClick, closeOnEscape); + + function handleCloseButtonClick(event) { + onCloseButtonClick(event); + handleClose(event); + } + + if (!isOpen) { + return null; + } + + return ( +
+
+ +
+
+ {title && ( +
+ {title} +
+ )} + {subtitle && ( +
+ {subtitle} +
+ )} + {children} +
+
+
+ + + {actionButtonLabel} + + + {!hideCloseButton && ( + + )} +
+ ); +} + +ActionableNotification.propTypes = { + /** + * Pass in the action button label that will be rendered within the ActionableNotification. + */ + actionButtonLabel: PropTypes.string.isRequired, + + /** + * Provide a description for "close" icon button that can be read by screen readers */ - actions: PropTypes.node, + ariaLabel: PropTypes.string, /** - * Pass in the children that will be rendered within the InlineNotification + * Specify the caption + */ + caption: PropTypes.string, + + /** + * Specify the content */ children: PropTypes.node, @@ -515,15 +662,25 @@ InlineNotification.propTypes = { */ className: PropTypes.string, + /** + * Specify if pressing the escape key should close notifications + */ + closeOnEscape: PropTypes.bool, + + /** + * Specify if focus should be moved to the component when the notification contains actions + */ + hasFocus: PropTypes.bool, + /** * Specify the close button should be disabled, or not */ hideCloseButton: PropTypes.bool, - /** - * Provide a description for "close" icon that can be read by screen readers + /* + * Specify if the notification should have inline styling applied instead of toast */ - iconDescription: PropTypes.string, + inline: PropTypes.bool, /** * Specify what state the notification represents @@ -538,15 +695,14 @@ InlineNotification.propTypes = { ]).isRequired, /** - * Specify whether you are using the low contrast variant of the InlineNotification. + * Specify whether you are using the low contrast variant of the ActionableNotification. */ lowContrast: PropTypes.bool, /** - * By default, this value is "inline". You can also provide an alternate type - * if it makes sense for the underlying `` and `` + * Provide a function that is called when the action is clicked */ - notificationType: PropTypes.string, + onActionButtonClick: PropTypes.func, /** * Provide a function that is called when menu is closed @@ -559,10 +715,10 @@ InlineNotification.propTypes = { onCloseButtonClick: PropTypes.func, /** - * By default, this value is "alert". You can also provide an alternate - * role if it makes sense from the accessibility-side + * By default, this value is "alertdialog". You can also provide an alternate + * role if it makes sense from the accessibility-side. */ - role: PropTypes.string.isRequired, + role: PropTypes.string, /** * Provide a description for "status" icon that can be read by screen readers @@ -572,18 +728,20 @@ InlineNotification.propTypes = { /** * Specify the sub-title */ - subtitle: PropTypes.node, + subtitle: PropTypes.string, /** * Specify the title */ - title: PropTypes.string.isRequired, + title: PropTypes.string, }; -InlineNotification.defaultProps = { - role: 'alert', - notificationType: 'inline', - iconDescription: 'closes notification', +ActionableNotification.defaultProps = { + kind: 'error', + role: 'alertdialog', onCloseButtonClick: () => {}, hideCloseButton: false, + hasFocus: true, + closeOnEscape: true, + inline: false, }; diff --git a/packages/react/src/components/Notification/Notification.mdx b/packages/react/src/components/Notification/Notification.mdx index 477227b95415..eea2568a58f9 100644 --- a/packages/react/src/components/Notification/Notification.mdx +++ b/packages/react/src/components/Notification/Notification.mdx @@ -1,4 +1,4 @@ -import { Props } from '@storybook/addon-docs'; +import { Story, Preview, Props } from '@storybook/addon-docs'; # Notification @@ -10,12 +10,39 @@ import { Props } from '@storybook/addon-docs'; ## Table of Contents +- [Overview](#overview) +- [Component API](#component-api) +- [Actionable](#actionable) +- [References](#references) +- [Feedback](#feedback) + ## Overview +There are 3 different types of notification components: +`ActionableNotification`, `InlineNotification`, and `ToastNotification`. + ## Component API +## Actionable + + + + + +## Inline + + + + + +## Toast + + + + + ## Feedback Help us improve this component by providing feedback, asking questions on Slack, diff --git a/packages/react/src/components/Notification/index.js b/packages/react/src/components/Notification/index.js index 7d060a62edc0..867e40e34e7b 100644 --- a/packages/react/src/components/Notification/index.js +++ b/packages/react/src/components/Notification/index.js @@ -5,53 +5,10 @@ * LICENSE file in the root directory of this source tree. */ -import { - NotificationActionButton as NotificationActionButtonNext, - NotificationButton as NotificationButtonNext, - ToastNotification as ToastNotificationNext, - InlineNotification as InlineNotificationNext, - ActionableNotification as ActionableNotificationNext, -} from './next/Notification'; -import { - NotificationActionButton as NotificationActionButtonClassic, - NotificationTextDetails as NotificationTextDetailsClassic, - NotificationButton as NotificationButtonClassic, - ToastNotification as ToastNotificationClassic, - InlineNotification as InlineNotificationClassic, +export { + NotificationActionButton, + NotificationButton, + ToastNotification, + InlineNotification, + ActionableNotification, } from './Notification'; -import { createComponentToggle } from '../../internal/ComponentToggle'; - -export const NotificationActionButton = createComponentToggle({ - name: 'NotificationActionButton', - next: NotificationActionButtonNext, - classic: NotificationActionButtonClassic, -}); - -export const NotificationTextDetails = createComponentToggle({ - name: 'NotificationTextDetails', - classic: NotificationTextDetailsClassic, -}); - -export const NotificationButton = createComponentToggle({ - name: 'NotificationButton', - next: NotificationButtonNext, - classic: NotificationButtonClassic, -}); - -export const ToastNotification = createComponentToggle({ - name: 'ToastNotification', - next: ToastNotificationNext, - classic: ToastNotificationClassic, -}); - -export const InlineNotification = createComponentToggle({ - name: 'InlineNotification', - next: InlineNotificationNext, - classic: InlineNotificationClassic, -}); - -export const ActionableNotification = createComponentToggle({ - name: 'ActionableNotification', - next: ActionableNotificationNext, - classic: null, -}); diff --git a/packages/react/src/components/Notification/next/Notification-test.js b/packages/react/src/components/Notification/next/Notification-test.js deleted file mode 100644 index eacac26e2c6c..000000000000 --- a/packages/react/src/components/Notification/next/Notification-test.js +++ /dev/null @@ -1,342 +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 { ErrorFilled } from '@carbon/icons-react'; -import { - NotificationButton, - ToastNotification, - InlineNotification, - ActionableNotification, -} from '../next/Notification'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { it } from 'window-or-global'; - -const prefix = 'cds'; - -describe('NotificationButton', () => { - it('should place the `className` prop on the outermost DOM node', () => { - const { container } = render(); - expect(container.firstChild).toHaveClass('test'); - }); - - it('supports custom icon', () => { - const { rerender } = render(); - const defaultIcon = screen.queryByRole('button').innerHTML; - - rerender( - } - /> - ); - const customIcon = screen.queryByRole('button').innerHTML; - - expect(defaultIcon).not.toEqual(customIcon); - }); - - it('interpolates matching className based on notificationType prop', () => { - const { rerender, container } = render(); - const notificationTypes = ['toast', 'inline']; - - notificationTypes.forEach((notificationType) => { - rerender(); - expect(container.firstChild).toHaveClass( - `${prefix}--${notificationType}-notification__close-button` - ); - expect(screen.queryByRole('button').firstChild).toHaveClass( - `${prefix}--${notificationType}-notification__close-icon` - ); - }); - }); -}); - -describe('ToastNotification', () => { - it('should have role=status by default', () => { - const { container } = render( - - ); - expect(container.firstChild).toHaveAttribute('role', 'status'); - }); - - it('should place the `className` prop on the outermost DOM node', () => { - const { container } = render( - - ); - expect(container.firstChild).toHaveClass('test'); - }); - - it('interpolates matching className based on kind prop', () => { - const { rerender } = render( - - ); - const kinds = [ - 'error', - 'info', - 'info-square', - 'success', - 'warning', - 'warning-alt', - ]; - kinds.forEach((kind) => { - rerender(); - expect(screen.queryByRole('status')).toHaveClass( - `${prefix}--toast-notification--${kind}` - ); - }); - }); - - it('allows non-interactive elements as children', () => { - render( - -

Sample text

-
- ); - expect(screen.queryByText(/Sample text/i)).toBeInTheDocument(); - }); - - it('does not allow interactive elements as children', () => { - const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - expect(() => { - render( - - - - ); - }).toThrow(); - - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it('close button is rendered by default and includes aria-hidden=true', () => { - render(); - - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - - expect(closeButton).toBeInTheDocument(); - expect(closeButton).toHaveAttribute('aria-hidden', 'true'); - }); - - it('does not render close button when `hideCloseButton` is provided', () => { - render(); - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - expect(closeButton).not.toBeInTheDocument(); - }); - - it('calls `onClose` when notification is closed', async () => { - const onClose = jest.fn(); - render(); - - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - userEvent.click(closeButton); - expect(onClose).toHaveBeenCalledTimes(1); - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - }); - - it('keeps notification open if `onClose` returns false', () => { - render( - false} /> - ); - - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - userEvent.click(closeButton); - expect(screen.queryByRole('status')).toBeInTheDocument(); - }); - - it('calls `onCloseButtonClick` when notification is closed', () => { - const onCloseButtonClick = jest.fn(); - render( - - ); - - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - userEvent.click(closeButton); - expect(onCloseButtonClick).toHaveBeenCalledTimes(1); - }); -}); - -describe('InlineNotification', () => { - it('should have role=status by default', () => { - const { container } = render( - - ); - expect(container.firstChild).toHaveAttribute('role', 'status'); - }); - - it('should place the `className` prop on the outermost DOM node', () => { - const { container } = render( - - ); - expect(container.firstChild).toHaveClass('test'); - }); - - it('interpolates matching className based on kind prop', () => { - const { rerender } = render( - - ); - const kinds = [ - 'error', - 'info', - 'info-square', - 'success', - 'warning', - 'warning-alt', - ]; - kinds.forEach((kind) => { - rerender(); - expect(screen.queryByRole('status')).toHaveClass( - `${prefix}--inline-notification--${kind}` - ); - }); - }); - - it('allows non-interactive elements as children', () => { - render( - -

Sample text

-
- ); - expect(screen.queryByText(/Sample text/i)).toBeInTheDocument(); - }); - - it('does not allow interactive elements as children', () => { - const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - expect(() => { - render( - - - - ); - }).toThrow(); - - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - - it('close button is rendered by default and includes aria-hidden=true', () => { - render(); - - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - expect(closeButton).toBeInTheDocument(); - expect(closeButton).toHaveAttribute('aria-hidden', 'true'); - }); - - it('does not render close button when `hideCloseButton` is provided', () => { - render(); - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - expect(closeButton).not.toBeInTheDocument(); - }); - - it('calls `onClose` when notification is closed', async () => { - const onClose = jest.fn(); - render(); - - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - userEvent.click(closeButton); - expect(onClose).toHaveBeenCalledTimes(1); - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - }); - - it('keeps notification open if `onClose` returns false', () => { - render( - false} /> - ); - - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - userEvent.click(closeButton); - expect(screen.queryByRole('status')).toBeInTheDocument(); - }); - - it('calls `onCloseButtonClick` when notification is closed', () => { - const onCloseButtonClick = jest.fn(); - render( - - ); - - const closeButton = screen.queryByRole('button', { - hidden: true, - }); - userEvent.click(closeButton); - expect(onCloseButtonClick).toHaveBeenCalledTimes(1); - }); -}); - -describe('ActionableNotification', () => { - it('uses role=alertdialog', () => { - const { container } = render( - - ); - - expect(container.firstChild).toHaveAttribute('role', 'alertdialog'); - }); - - it('renders correct action label', () => { - render(); - const actionButton = screen.queryByRole('button', { - name: 'My custom action', - }); - expect(actionButton).toBeInTheDocument(); - }); - - it('closes notification via escape button when focus is placed on the notification', async () => { - const onCloseButtonClick = jest.fn(); - const onClose = jest.fn(); - render( - - ); - - // without focus being on/in the notification, it should not close via escape - userEvent.keyboard('{Escape}'); - expect(onCloseButtonClick).toHaveBeenCalledTimes(0); - expect(onClose).toHaveBeenCalledTimes(0); - - // after focus is placed, the notification should close via escape - userEvent.tab(); - userEvent.keyboard('{Escape}'); - expect(onCloseButtonClick).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - - await waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); - }); - }); -}); diff --git a/packages/react/src/components/Notification/next/Notification.js b/packages/react/src/components/Notification/next/Notification.js deleted file mode 100644 index bf51f9941fef..000000000000 --- a/packages/react/src/components/Notification/next/Notification.js +++ /dev/null @@ -1,747 +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 PropTypes from 'prop-types'; -import React, { useEffect, useRef, useState } from 'react'; -import cx from 'classnames'; -import { - Close, - ErrorFilled, - CheckmarkFilled, - WarningFilled, - WarningAltFilled, - InformationFilled, - InformationSquareFilled, -} from '@carbon/icons-react'; - -import Button from '../../Button'; -import useIsomorphicEffect from '../../../internal/useIsomorphicEffect'; -import { useNoInteractiveChildren } from '../../../internal/useNoInteractiveChildren'; -import { keys, matches } from '../../../internal/keyboard'; -import { usePrefix } from '../../../internal/usePrefix'; - -/** - * Conditionally call a callback when the escape key is pressed - * @param {node} ref - ref of the container element to scope the functionality to - * @param {func} callback - function to be called - * @param {bool} override - escape hatch to conditionally call the callback - */ -function useEscapeToClose(ref, callback, override = true) { - const handleKeyDown = (event) => { - // The callback should only be called when focus is on or within the container - const elementContainsFocus = - (ref.current && document.activeElement === ref.current) || - ref.current.contains(document.activeElement); - - if (matches(event, [keys.Escape]) && override && elementContainsFocus) { - callback(event); - } - }; - - useIsomorphicEffect(() => { - document.addEventListener('keydown', handleKeyDown, false); - return () => document.removeEventListener('keydown', handleKeyDown, false); - }); -} - -export function NotificationActionButton({ - children, - className: customClassName, - onClick, - inline, - ...rest -}) { - const prefix = usePrefix(); - const className = cx(customClassName, { - [`${prefix}--actionable-notification__action-button`]: true, - }); - - return ( - - ); -} - -NotificationActionButton.propTypes = { - /** - * Specify the content of the notification action button. - */ - children: PropTypes.node, - - /** - * Specify an optional className to be applied to the notification action button - */ - className: PropTypes.string, - - /** - * Specify if the visual treatment of the button should be for an inline notification - */ - inline: PropTypes.bool, - - /** - * Optionally specify a click handler for the notification action button. - */ - onClick: PropTypes.func, -}; - -export function NotificationButton({ - ariaLabel, - className, - type, - renderIcon: IconTag, - name, - notificationType, - ...rest -}) { - const prefix = usePrefix(); - const buttonClassName = cx(className, { - [`${prefix}--${notificationType}-notification__close-button`]: - notificationType, - }); - const iconClassName = cx({ - [`${prefix}--${notificationType}-notification__close-icon`]: - notificationType, - }); - - return ( - - ); -} - -NotificationButton.propTypes = { - /** - * Specify a label to be read by screen readers on the notification button - */ - ariaLabel: PropTypes.string, - - /** - * Specify an optional className to be applied to the notification button - */ - className: PropTypes.string, - - /** - * Specify an optional icon for the Button through a string, - * if something but regular "close" icon is desirable - */ - name: PropTypes.string, - - /** - * Specify the notification type - */ - notificationType: PropTypes.oneOf(['toast', 'inline', 'actionable']), - - /** - * Optional prop to allow overriding the icon rendering. - * Can be a React component class - */ - renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - - /** - * Optional prop to specify the type of the Button - */ - type: PropTypes.string, -}; - -NotificationButton.defaultProps = { - ariaLabel: 'close notification', - notificationType: 'toast', - type: 'button', - renderIcon: Close, -}; - -const iconTypes = { - error: ErrorFilled, - success: CheckmarkFilled, - warning: WarningFilled, - ['warning-alt']: WarningAltFilled, - info: InformationFilled, - ['info-square']: InformationSquareFilled, -}; - -function NotificationIcon({ iconDescription, kind, notificationType }) { - const prefix = usePrefix(); - const IconForKind = iconTypes[kind]; - if (!IconForKind) { - return null; - } - return ( - - {iconDescription} - - ); -} - -NotificationIcon.propTypes = { - iconDescription: PropTypes.string.isRequired, - kind: PropTypes.oneOf([ - 'error', - 'success', - 'warning', - 'warning-alt', - 'info', - 'info-square', - ]).isRequired, - notificationType: PropTypes.oneOf(['inline', 'toast']).isRequired, -}; - -export function ToastNotification({ - role, - onClose, - onCloseButtonClick, - statusIconDescription, - className, - children, - kind, - lowContrast, - hideCloseButton, - timeout, - title, - caption, - subtitle, - ...rest -}) { - const [isOpen, setIsOpen] = useState(true); - const prefix = usePrefix(); - const containerClassName = cx(className, { - [`${prefix}--toast-notification`]: true, - [`${prefix}--toast-notification--low-contrast`]: lowContrast, - [`${prefix}--toast-notification--${kind}`]: kind, - }); - - const contentRef = useRef(null); - useNoInteractiveChildren(contentRef); - - const handleClose = (evt) => { - if (!onClose || onClose(evt) !== false) { - setIsOpen(false); - } - }; - const ref = useRef(null); - - function handleCloseButtonClick(event) { - onCloseButtonClick(event); - handleClose(event); - } - - const savedOnClose = useRef(onClose); - - useEffect(() => { - savedOnClose.current = onClose; - }); - - useEffect(() => { - if (!timeout) { - return; - } - - const timeoutId = window.setTimeout((event) => { - setIsOpen(false); - if (savedOnClose.current) { - savedOnClose.current(event); - } - }, timeout); - return () => { - window.clearTimeout(timeoutId); - }; - }, [timeout]); - - if (!isOpen) { - return null; - } - - return ( -
- -
- {title && ( -
{title}
- )} - {subtitle && ( -
- {subtitle} -
- )} - {caption && ( -
- {caption} -
- )} - {children} -
- {!hideCloseButton && ( -
- ); -} - -ToastNotification.propTypes = { - /** - * Provide a description for "close" icon button that can be read by screen readers - */ - ariaLabel: PropTypes.string, - - /** - * Specify the caption - */ - caption: PropTypes.string, - - /** - * Specify the content - */ - children: PropTypes.node, - - /** - * Specify an optional className to be applied to the notification box - */ - className: PropTypes.string, - - /** - * Specify the close button should be disabled, or not - */ - hideCloseButton: PropTypes.bool, - - /** - * Specify what state the notification represents - */ - kind: PropTypes.oneOf([ - 'error', - 'info', - 'info-square', - 'success', - 'warning', - 'warning-alt', - ]), - - /** - * Specify whether you are using the low contrast variant of the ToastNotification. - */ - lowContrast: PropTypes.bool, - - /** - * Provide a function that is called when menu is closed - */ - onClose: PropTypes.func, - - /** - * Provide a function that is called when the close button is clicked - */ - onCloseButtonClick: PropTypes.func, - - /** - * By default, this value is "status". You can also provide an alternate - * role if it makes sense from the accessibility-side - */ - role: PropTypes.oneOf(['alert', 'log', 'status']), - - /** - * Provide a description for "status" icon that can be read by screen readers - */ - statusIconDescription: PropTypes.string, - - /** - * Specify the sub-title - */ - subtitle: PropTypes.string, - - /** - * Specify an optional duration the notification should be closed in - */ - timeout: PropTypes.number, - - /** - * Specify the title - */ - title: PropTypes.string, -}; - -ToastNotification.defaultProps = { - kind: 'error', - role: 'status', - onCloseButtonClick: () => {}, - hideCloseButton: false, - timeout: 0, -}; - -export function InlineNotification({ - children, - title, - subtitle, - role, - onClose, - onCloseButtonClick, - statusIconDescription, - className, - kind, - lowContrast, - hideCloseButton, - ...rest -}) { - const [isOpen, setIsOpen] = useState(true); - const prefix = usePrefix(); - const containerClassName = cx(className, { - [`${prefix}--inline-notification`]: true, - [`${prefix}--inline-notification--low-contrast`]: lowContrast, - [`${prefix}--inline-notification--${kind}`]: kind, - [`${prefix}--inline-notification--hide-close-button`]: hideCloseButton, - }); - - const contentRef = useRef(null); - useNoInteractiveChildren(contentRef); - - const handleClose = (evt) => { - if (!onClose || onClose(evt) !== false) { - setIsOpen(false); - } - }; - const ref = useRef(null); - - function handleCloseButtonClick(event) { - onCloseButtonClick(event); - handleClose(event); - } - - if (!isOpen) { - return null; - } - - return ( -
-
- -
- {title && ( -
- {title} -
- )} - {subtitle && ( -
- {subtitle} -
- )} - {children} -
-
- {!hideCloseButton && ( -
- ); -} - -InlineNotification.propTypes = { - /** - * Specify the content - */ - children: PropTypes.node, - - /** - * Specify an optional className to be applied to the notification box - */ - className: PropTypes.string, - - /** - * Specify the close button should be disabled, or not - */ - hideCloseButton: PropTypes.bool, - - /** - * Specify what state the notification represents - */ - kind: PropTypes.oneOf([ - 'error', - 'info', - 'info-square', - 'success', - 'warning', - 'warning-alt', - ]), - - /** - * Specify whether you are using the low contrast variant of the InlineNotification. - */ - lowContrast: PropTypes.bool, - - /** - * Provide a function that is called when menu is closed - */ - onClose: PropTypes.func, - - /** - * Provide a function that is called when the close button is clicked - */ - onCloseButtonClick: PropTypes.func, - - /** - * By default, this value is "status". You can also provide an alternate - * role if it makes sense from the accessibility-side. - */ - role: PropTypes.oneOf(['alert', 'log', 'status']), - - /** - * Provide a description for "status" icon that can be read by screen readers - */ - statusIconDescription: PropTypes.string, - - /** - * Specify the sub-title - */ - subtitle: PropTypes.string, - - /** - * Specify the title - */ - title: PropTypes.string, -}; - -InlineNotification.defaultProps = { - kind: 'error', - role: 'status', - onCloseButtonClick: () => {}, - hideCloseButton: false, -}; - -export function ActionableNotification({ - actionButtonLabel, - ariaLabel, - children, - role, - onActionButtonClick, - onClose, - onCloseButtonClick, - statusIconDescription, - className, - inline, - kind, - lowContrast, - hideCloseButton, - hasFocus, - closeOnEscape, - title, - subtitle, - ...rest -}) { - const [isOpen, setIsOpen] = useState(true); - const prefix = usePrefix(); - const containerClassName = cx(className, { - [`${prefix}--actionable-notification`]: true, - [`${prefix}--actionable-notification--toast`]: !inline, - [`${prefix}--actionable-notification--low-contrast`]: lowContrast, - [`${prefix}--actionable-notification--${kind}`]: kind, - [`${prefix}--actionable-notification--hide-close-button`]: hideCloseButton, - }); - - const ref = useRef(null); - useIsomorphicEffect(() => { - if (ref.current && hasFocus) { - ref.current.focus(); - } - }); - - const handleClose = (evt) => { - if (!onClose || onClose(evt) !== false) { - setIsOpen(false); - } - }; - useEscapeToClose(ref, handleCloseButtonClick, closeOnEscape); - - function handleCloseButtonClick(event) { - onCloseButtonClick(event); - handleClose(event); - } - - if (!isOpen) { - return null; - } - - return ( -
-
- -
-
- {title && ( -
- {title} -
- )} - {subtitle && ( -
- {subtitle} -
- )} - {children} -
-
-
- - - {actionButtonLabel} - - - {!hideCloseButton && ( - - )} -
- ); -} - -ActionableNotification.propTypes = { - /** - * Pass in the action button label that will be rendered within the ActionableNotification. - */ - actionButtonLabel: PropTypes.string.isRequired, - - /** - * Provide a description for "close" icon button that can be read by screen readers - */ - ariaLabel: PropTypes.string, - - /** - * Specify the caption - */ - caption: PropTypes.string, - - /** - * Specify the content - */ - children: PropTypes.node, - - /** - * Specify an optional className to be applied to the notification box - */ - className: PropTypes.string, - - /** - * Specify if pressing the escape key should close notifications - */ - closeOnEscape: PropTypes.bool, - - /** - * Specify if focus should be moved to the component when the notification contains actions - */ - hasFocus: PropTypes.bool, - - /** - * Specify the close button should be disabled, or not - */ - hideCloseButton: PropTypes.bool, - - /* - * Specify if the notification should have inline styling applied instead of toast - */ - inline: PropTypes.bool, - - /** - * Specify what state the notification represents - */ - kind: PropTypes.oneOf([ - 'error', - 'info', - 'info-square', - 'success', - 'warning', - 'warning-alt', - ]).isRequired, - - /** - * Specify whether you are using the low contrast variant of the ActionableNotification. - */ - lowContrast: PropTypes.bool, - - /** - * Provide a function that is called when the action is clicked - */ - onActionButtonClick: PropTypes.func, - - /** - * Provide a function that is called when menu is closed - */ - onClose: PropTypes.func, - - /** - * Provide a function that is called when the close button is clicked - */ - onCloseButtonClick: PropTypes.func, - - /** - * By default, this value is "alertdialog". You can also provide an alternate - * role if it makes sense from the accessibility-side. - */ - role: PropTypes.string, - - /** - * Provide a description for "status" icon that can be read by screen readers - */ - statusIconDescription: PropTypes.string, - - /** - * Specify the sub-title - */ - subtitle: PropTypes.string, - - /** - * Specify the title - */ - title: PropTypes.string, -}; - -ActionableNotification.defaultProps = { - kind: 'error', - role: 'alertdialog', - onCloseButtonClick: () => {}, - hideCloseButton: false, - hasFocus: true, - closeOnEscape: true, - inline: false, -}; diff --git a/packages/react/src/components/Notification/next/Notification.stories.js b/packages/react/src/components/Notification/next/Notification.stories.js deleted file mode 100644 index 5767c7557924..000000000000 --- a/packages/react/src/components/Notification/next/Notification.stories.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 { FeatureFlags } from '../../FeatureFlags'; -import { - ActionableNotification, - ToastNotification, - InlineNotification, -} from './Notification'; -import React from 'react'; -import { action } from '@storybook/addon-actions'; - -// eslint-disable-next-line storybook/csf-component -export default { - title: 'Components/Notifications', - subscomponents: { - ActionableNotification, - InlineNotification, - ToastNotification, - }, - decorators: [ - (Story) => ( - - - - ), - ], - argTypes: { - kind: { - options: [ - 'error', - 'info', - 'info-square', - 'success', - 'warning', - 'warning-alt', - ], - control: { - type: 'select', - }, - }, - className: { - control: { - type: 'text', - }, - }, - }, - args: { - kind: 'error', - lowContrast: false, - hideCloseButton: false, - ariaLabel: 'closes notification', - statusIconDescription: 'notification', - onClose: action('onClose'), - onCloseButtonClick: action('onCloseButtonClick'), - }, -}; - -const ToastStory = (args) => ; - -export const Toast = ToastStory.bind({}); - -Toast.argTypes = { - role: { - options: ['alert', 'log', 'status'], - control: { - type: 'select', - }, - }, - caption: { - control: { - type: 'text', - }, - }, - title: { - control: { - type: 'text', - }, - }, - subtitle: { - control: { - type: 'text', - }, - }, -}; -Toast.args = { - role: 'status', - caption: '00:00:00 AM', - timeout: 0, - title: 'Notification title', - subtitle: 'Subtitle text goes here', -}; - -const InlineStory = (args) => { - return ; -}; - -export const Inline = InlineStory.bind({}); - -Inline.argTypes = { - role: { - options: ['alert', 'log', 'status'], - control: { - type: 'select', - }, - }, - title: { - control: { - type: 'text', - }, - }, - subtitle: { - control: { - type: 'text', - }, - }, -}; - -Inline.args = { - title: 'Notification title', - subtitle: 'Subtitle text goes here', -}; - -export const Actionable = (args) => ; - -Actionable.args = { - actionButtonLabel: 'Action', - inline: false, - closeOnEscape: true, - title: 'Notification title', - subtitle: 'Subtitle text goes here', -}; diff --git a/packages/react/src/components/Notification/stories/ActionableNotification.stories.js b/packages/react/src/components/Notification/stories/ActionableNotification.stories.js new file mode 100644 index 000000000000..dd1a7fd85a25 --- /dev/null +++ b/packages/react/src/components/Notification/stories/ActionableNotification.stories.js @@ -0,0 +1,76 @@ +/** + * 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 { ActionableNotification } from '../../Notification'; +import { action } from '@storybook/addon-actions'; +import mdx from '../Notification.mdx'; + +// eslint-disable-next-line storybook/csf-component +export default { + title: 'Components/Notifications/Actionable', + component: ActionableNotification, + parameters: { + docs: { + page: mdx, + }, + }, + args: { + kind: 'error', + lowContrast: false, + hideCloseButton: false, + ariaLabel: 'closes notification', + statusIconDescription: 'notification', + onClose: action('onClose'), + onCloseButtonClick: action('onCloseButtonClick'), + }, +}; + +export const Default = () => ( + +); + +export const Playground = (args) => ; + +Playground.argTypes = { + ariaLabel: { + table: { + disable: true, + }, + }, + onActionButtonClick: { + action: 'clicked', + }, + onClose: { + action: 'clicked', + }, + onCloseButtonClick: { + action: 'clicked', + }, + children: { + table: { + disable: true, + }, + }, + className: { + table: { + disable: true, + }, + }, +}; +Playground.args = { + actionButtonLabel: 'Action', + inline: false, + title: 'Notification title', + subtitle: 'Subtitle text goes here', +}; diff --git a/packages/react/src/components/Notification/stories/InlineNotification.stories.js b/packages/react/src/components/Notification/stories/InlineNotification.stories.js new file mode 100644 index 000000000000..6aaa7264e124 --- /dev/null +++ b/packages/react/src/components/Notification/stories/InlineNotification.stories.js @@ -0,0 +1,80 @@ +/** + * 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 { InlineNotification } from '../../Notification'; +import { action } from '@storybook/addon-actions'; +import mdx from '../Notification.mdx'; + +// eslint-disable-next-line storybook/csf-component +export default { + title: 'Components/Notifications/Inline', + component: InlineNotification, + parameters: { + docs: { + page: mdx, + }, + }, + args: { + kind: 'error', + lowContrast: false, + hideCloseButton: false, + ariaLabel: 'closes notification', + statusIconDescription: 'notification', + onClose: action('onClose'), + onCloseButtonClick: action('onCloseButtonClick'), + }, +}; + +export const Default = () => ( + +); + +export const Playground = (args) => ; + +Playground.argTypes = { + actionButtonLabel: { + table: { + disable: true, + }, + }, + ariaLabel: { + table: { + disable: true, + }, + }, + onActionButtonClick: { + table: { + disable: true, + }, + }, + onClose: { + action: 'clicked', + }, + onCloseButtonClick: { + action: 'clicked', + }, + children: { + table: { + disable: true, + }, + }, + className: { + table: { + disable: true, + }, + }, +}; +Playground.args = { + actionButtonLabel: 'Action', + inline: false, + title: 'Notification title', + subtitle: 'Subtitle text goes here', +}; diff --git a/packages/react/src/components/Notification/stories/ToastNotification.stories.js b/packages/react/src/components/Notification/stories/ToastNotification.stories.js new file mode 100644 index 000000000000..b0f1d8a3edcc --- /dev/null +++ b/packages/react/src/components/Notification/stories/ToastNotification.stories.js @@ -0,0 +1,84 @@ +/** + * 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 { ToastNotification } from '../../Notification'; +import { action } from '@storybook/addon-actions'; +import mdx from '../Notification.mdx'; + +// eslint-disable-next-line storybook/csf-component +export default { + title: 'Components/Notifications/Toast', + component: ToastNotification, + parameters: { + docs: { + page: mdx, + }, + }, + args: { + kind: 'error', + lowContrast: false, + hideCloseButton: false, + ariaLabel: 'closes notification', + statusIconDescription: 'notification', + onClose: action('onClose'), + onCloseButtonClick: action('onCloseButtonClick'), + }, +}; + +export const Default = () => ( + +); + +export const Playground = (args) => ; + +Playground.argTypes = { + actionButtonLabel: { + table: { + disable: true, + }, + }, + ariaLabel: { + table: { + disable: true, + }, + }, + onActionButtonClick: { + table: { + disable: true, + }, + }, + onClose: { + action: 'clicked', + }, + onCloseButtonClick: { + action: 'clicked', + }, + children: { + table: { + disable: true, + }, + }, + className: { + table: { + disable: true, + }, + }, +}; +Playground.args = { + role: 'status', + caption: '00:00:00 AM', + timeout: 0, + title: 'Notification title', + subtitle: 'Subtitle text goes here', +}; 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