From ea279f56c2af439ec78cfd54849fb17f9d15b489 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 7 Dec 2021 11:32:12 -0600 Subject: [PATCH 1/5] docs(data-table): update batch action story to hide content (#10235) Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../stories/DataTable-batch-actions-story.js | 155 +++++++++--------- 1 file changed, 80 insertions(+), 75 deletions(-) diff --git a/packages/react/src/components/DataTable/stories/DataTable-batch-actions-story.js b/packages/react/src/components/DataTable/stories/DataTable-batch-actions-story.js index 631e69a2f69b..164752e6664d 100644 --- a/packages/react/src/components/DataTable/stories/DataTable-batch-actions-story.js +++ b/packages/react/src/components/DataTable/stories/DataTable-batch-actions-story.js @@ -76,82 +76,87 @@ export const Usage = () => ( selectedRows, getTableProps, getTableContainerProps, - }) => ( - - - - - Delete - - - Save - - - Download - - - - - - alert('Alert 1')}> - Action 1 - - alert('Alert 2')}> - Action 2 - - alert('Alert 3')}> - Action 3 - - - - - - - - - - {headers.map((header, i) => ( - - {header.header} - - ))} - - - - {rows.map((row, i) => ( - - - {row.cells.map((cell) => ( - {cell.value} + }) => { + const batchActionProps = getBatchActionProps(); + + return ( + + + + + Delete + + + Save + + + Download + + + + + + alert('Alert 1')}> + Action 1 + + alert('Alert 2')}> + Action 2 + + alert('Alert 3')}> + Action 3 + + + + + +
+ + + + {headers.map((header, i) => ( + + {header.header} + ))} - ))} - -
-
- )} + + + {rows.map((row, i) => ( + + + {row.cells.map((cell) => ( + {cell.value} + ))} + + ))} + + + + ); + }} ); From 1d3e95ffd9a837dc04934ec599f6ff089d93b8cf Mon Sep 17 00:00:00 2001 From: Kevin Perrine Date: Tue, 7 Dec 2021 12:54:16 -0500 Subject: [PATCH 2/5] feat(dropdown): add renderSelectedItem prop (#10188) * feat(dropdown): add renderSelectedItem prop * chore(dropdown): more robust renderSelectedItem story Co-authored-by: DAK <40970507+dakahn@users.noreply.github.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../__snapshots__/PublicAPI-test.js.snap | 3 ++ .../src/components/Dropdown/Dropdown-story.js | 50 +++++++++++++++++++ .../src/components/Dropdown/Dropdown-test.js | 25 ++++++++++ .../react/src/components/Dropdown/Dropdown.js | 13 ++++- 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 580ba15890c8..4dbda9318f52 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -2645,6 +2645,9 @@ Map { "onChange": Object { "type": "func", }, + "renderSelectedItem": Object { + "type": "func", + }, "selectedItem": Object { "args": Array [ Array [ diff --git a/packages/react/src/components/Dropdown/Dropdown-story.js b/packages/react/src/components/Dropdown/Dropdown-story.js index 2da0b3ec8b55..17f28e5b0418 100644 --- a/packages/react/src/components/Dropdown/Dropdown-story.js +++ b/packages/react/src/components/Dropdown/Dropdown-story.js @@ -17,6 +17,11 @@ import { import Dropdown from '../Dropdown'; import DropdownSkeleton from './Dropdown.Skeleton'; import mdx from './Dropdown.mdx'; +import { + ChartBubble16, + ChartColumnFloating16, + ChartVennDiagram16, +} from '@carbon/icons-react'; const items = [ { @@ -122,6 +127,51 @@ export const Default = () => ( /> ); +export const RenderSelectedItem = () => ( +
+ (item ? item.text : '')} + itemToElement={(item) => ( + <> + {React.createElement(item.icon)} + + {item.text} + + + )} + renderSelectedItem={(item) => ( + <> + {React.createElement(item.icon)} + + {item.text} + + + )} + onChange={action('onChange')} + /> +
+); export const Inline = () => (
diff --git a/packages/react/src/components/Dropdown/Dropdown-test.js b/packages/react/src/components/Dropdown/Dropdown-test.js index 903f92b5c52d..3b5ed0807f71 100644 --- a/packages/react/src/components/Dropdown/Dropdown-test.js +++ b/packages/react/src/components/Dropdown/Dropdown-test.js @@ -65,6 +65,31 @@ describe('Dropdown', () => { expect(wrapper).toMatchSnapshot(); }); + it('should render selectedItem as an element', () => { + const wrapper = mount( + ( +
+ {selectedItem.label} +
+ )} + /> + ); + // custom element should be rendered for the selected item + expect(wrapper.find('#a-custom-element-for-selected-item')).toHaveLength(1); + // the title should use the normal itemToString method + expect(wrapper.find('button').instance()).toHaveAttribute( + 'title', + 'Item 1' + ); + }); + describe('title', () => { let wrapper; let renderedLabel; diff --git a/packages/react/src/components/Dropdown/Dropdown.js b/packages/react/src/components/Dropdown/Dropdown.js index 789e4857ee12..b4c18490afb6 100644 --- a/packages/react/src/components/Dropdown/Dropdown.js +++ b/packages/react/src/components/Dropdown/Dropdown.js @@ -39,6 +39,7 @@ const Dropdown = React.forwardRef(function Dropdown( ariaLabel, itemToString, itemToElement, + renderSelectedItem, type, size, onChange, @@ -174,7 +175,11 @@ const Dropdown = React.forwardRef(function Dropdown( {...toggleButtonProps} ref={mergeRefs(toggleButtonProps.ref, ref)}> - {selectedItem ? itemToString(selectedItem) : label} + {selectedItem + ? renderSelectedItem + ? renderSelectedItem(selectedItem) + : itemToString(selectedItem) + : label} @@ -329,6 +334,12 @@ Dropdown.propTypes = { */ onChange: PropTypes.func, + /** + * An optional callback to render the currently selected item as a react element instead of only + * as a string. + */ + renderSelectedItem: PropTypes.func, + /** * In the case you want to control the dropdown selection entirely. */ From 32229f0190a793b4e0e4c6e760716b7202a1e7e2 Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Tue, 7 Dec 2021 12:20:32 -0600 Subject: [PATCH 3/5] feat(react): refactor filterable multi select to function (#10198) * chore: check in progress * feat(react): refactor FilterableMultiSelect * fix(react): reset Filterable field on multiselct * fix(styles): remove unnecessary styles * fix(react): update snapshot * chore(react): update to follow code style guidelines * Update packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js Co-authored-by: Taylor Jones * Update packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js Co-authored-by: Josh Black * Update packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.js Co-authored-by: Josh Black * fix(react): clear input and reset focus through user action * chore(react): format file Co-authored-by: Taylor Jones Co-authored-by: Josh Black Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../__snapshots__/PublicAPI-test.js.snap | 2 - .../components/FilterableMultiSelect/index.js | 2 +- .../react/src/components/MultiSelect/index.js | 13 +- .../MultiSelect/next/FilterableMultiSelect.js | 587 ++++++++++++++++++ .../MultiSelect/next/MultiSelect.stories.js | 9 +- .../FilterableMultiSelect-test.e2e.js | 136 ++++ .../__tests__/FilterableMultiSelect-test.js | 166 +++++ .../FilterableMultiSelect-test.js.snap | 187 ++++++ packages/react/src/index.js | 2 +- .../scss/components/list-box/_list-box.scss | 4 +- 10 files changed, 1095 insertions(+), 13 deletions(-) create mode 100644 packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js create mode 100644 packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.e2e.js create mode 100644 packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.js create mode 100644 packages/react/src/components/MultiSelect/next/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 4dbda9318f52..a8df06ae14a5 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -3108,7 +3108,6 @@ Map { "selectionFeedback": "top-after-reopen", "sortItems": [Function], }, - "displayName": "MultiSelect.Filterable", "propTypes": Object { "ariaLabel": Object { "type": "string", @@ -4074,7 +4073,6 @@ Map { "selectionFeedback": "top-after-reopen", "sortItems": [Function], }, - "displayName": "MultiSelect.Filterable", "propTypes": Object { "ariaLabel": Object { "type": "string", diff --git a/packages/react/src/components/FilterableMultiSelect/index.js b/packages/react/src/components/FilterableMultiSelect/index.js index 2c2e5612e6e3..13c57579c3a1 100644 --- a/packages/react/src/components/FilterableMultiSelect/index.js +++ b/packages/react/src/components/FilterableMultiSelect/index.js @@ -1 +1 @@ -export default from '../MultiSelect/FilterableMultiSelect'; +export { FilterableMultiSelect } from '../MultiSelect'; diff --git a/packages/react/src/components/MultiSelect/index.js b/packages/react/src/components/MultiSelect/index.js index c61b45aec598..6a1bd6854598 100644 --- a/packages/react/src/components/MultiSelect/index.js +++ b/packages/react/src/components/MultiSelect/index.js @@ -4,13 +4,20 @@ * 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 * as FeatureFlags from '@carbon/feature-flags'; import { deprecateFieldOnObject } from '../../internal/deprecateFieldOnObject'; import MultiSelect from './MultiSelect'; -import FilterableMultiSelect from './FilterableMultiSelect'; +import { default as FilterableMultiSelectClassic } from './FilterableMultiSelect'; +import { default as FilterableMultiSelectNext } from './next/FilterableMultiSelect'; + +FilterableMultiSelectNext.displayName = 'MultiSelect.Filterable'; +MultiSelect.Filterable = FilterableMultiSelectClassic; -FilterableMultiSelect.displayName = 'MultiSelect.Filterable'; -MultiSelect.Filterable = FilterableMultiSelect; +export const FilterableMultiSelect = FeatureFlags.enabled('enable-v11-release') + ? FilterableMultiSelectNext + : FilterableMultiSelectClassic; if (__DEV__) { deprecateFieldOnObject(MultiSelect, 'Filterable', FilterableMultiSelect); diff --git a/packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js b/packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js new file mode 100644 index 000000000000..4bb99a7afbf5 --- /dev/null +++ b/packages/react/src/components/MultiSelect/next/FilterableMultiSelect.js @@ -0,0 +1,587 @@ +/** + * 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 { WarningFilled16, WarningAltFilled16 } 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 { mapDownshiftProps } from '../../../tools/createPropAdapter'; +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, + 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 (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) { + 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, + + /** + * `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, +}; + +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/MultiSelect.stories.js b/packages/react/src/components/MultiSelect/next/MultiSelect.stories.js index 8637fc29c9df..180293490f2c 100644 --- a/packages/react/src/components/MultiSelect/next/MultiSelect.stories.js +++ b/packages/react/src/components/MultiSelect/next/MultiSelect.stories.js @@ -7,6 +7,7 @@ import React from 'react'; import MultiSelect from '../'; +import FilterableMultiSelect from './FilterableMultiSelect'; import { Layer } from '../../Layer'; const items = [ @@ -84,7 +85,7 @@ export const WithInitialSelectedItems = () => { export const _Filterable = () => { return (
- { export const _FilterableWithLayer = () => { return (
- { selectionFeedback="top-after-reopen" /> - { selectionFeedback="top-after-reopen" /> - { + 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 new file mode 100644 index 000000000000..9633bb65af72 --- /dev/null +++ b/packages/react/src/components/MultiSelect/next/__tests__/FilterableMultiSelect-test.js @@ -0,0 +1,166 @@ +/** + * 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 new file mode 100644 index 000000000000..924ee24ae8a4 --- /dev/null +++ b/packages/react/src/components/MultiSelect/next/__tests__/__snapshots__/FilterableMultiSelect-test.js.snap @@ -0,0 +1,187 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FilterableMultiSelect should render 1`] = ` + + + +
+ +
+
+ + + + +
+
+
+
+
+
+
+`; diff --git a/packages/react/src/index.js b/packages/react/src/index.js index 8e08033611e1..d3fba943902d 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -58,7 +58,7 @@ export FileUploader, { FileUploaderDropContainer, FileUploaderItem, } from './components/FileUploader'; -export FilterableMultiSelect from './components/FilterableMultiSelect'; +export { FilterableMultiSelect } from './components/FilterableMultiSelect'; export Form from './components/Form'; export FluidForm from './components/FluidForm'; export FormGroup from './components/FormGroup'; diff --git a/packages/styles/scss/components/list-box/_list-box.scss b/packages/styles/scss/components/list-box/_list-box.scss index 9932acc73079..6a2e92d383c2 100644 --- a/packages/styles/scss/components/list-box/_list-box.scss +++ b/packages/styles/scss/components/list-box/_list-box.scss @@ -743,8 +743,8 @@ $list-box-menu-width: rem(300px); .#{$prefix}--list-box__menu-item--active:hover, .#{$prefix}--list-box__menu-item--active.#{$prefix}--list-box__menu-item--highlighted { - border-bottom-color: $layer-selected; - background-color: $layer-selected; + border-bottom-color: $layer-selected-hover; + background-color: $layer-selected-hover; } .#{$prefix}--list-box__menu-item--active From f6e5dd2362aa2970add45c7fb7fc32841c252671 Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Tue, 7 Dec 2021 15:14:47 -0600 Subject: [PATCH 4/5] chore(release): v10.50.0-rc.0 --- packages/carbon-react/package.json | 10 +++---- packages/components/package.json | 4 +-- packages/elements/package.json | 6 ++--- packages/react/package.json | 4 +-- packages/sketch/package.json | 6 ++--- packages/styles/package.json | 6 ++--- packages/themes/package.json | 4 +-- packages/type/package.json | 2 +- www/package.json | 4 +-- yarn.lock | 42 +++++++++++++++--------------- 10 files changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/carbon-react/package.json b/packages/carbon-react/package.json index 5ff4fbccca7d..ea2eae980174 100644 --- a/packages/carbon-react/package.json +++ b/packages/carbon-react/package.json @@ -1,7 +1,7 @@ { "name": "@carbon/react", "description": "React components for the Carbon Design System", - "version": "0.10.0", + "version": "0.11.0-rc.0", "license": "Apache-2.0", "main": "lib/index.js", "module": "es/index.js", @@ -44,10 +44,10 @@ "dependencies": { "@carbon/feature-flags": "^0.6.0", "@carbon/icons-react": "^10.44.0", - "@carbon/styles": "^0.10.0", + "@carbon/styles": "^0.11.0-rc.0", "@carbon/telemetry": "0.0.0-alpha.6", - "carbon-components": "^10.49.0", - "carbon-components-react": "^7.49.0", + "carbon-components": "^10.50.0-rc.0", + "carbon-components-react": "^7.50.0-rc.0", "carbon-icons": "^7.0.7" }, "devDependencies": { @@ -58,7 +58,7 @@ "@babel/plugin-transform-react-constant-elements": "^7.14.5", "@babel/preset-env": "^7.14.7", "@babel/preset-react": "^7.14.5", - "@carbon/themes": "^10.47.0", + "@carbon/themes": "^10.48.0-rc.0", "@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-commonjs": "^18.0.0", "@rollup/plugin-node-resolve": "^11.2.1", diff --git a/packages/components/package.json b/packages/components/package.json index 197ff749a53b..9f34d9e003ff 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,7 +1,7 @@ { "name": "carbon-components", "description": "The Carbon Design System is IBM’s open-source design system for products and experiences.", - "version": "10.49.0", + "version": "10.50.0-rc.0", "license": "Apache-2.0", "main": "umd/index.js", "module": "es/index.js", @@ -81,7 +81,7 @@ "@babel/preset-react": "^7.14.5", "@babel/runtime": "^7.14.6", "@carbon/cli": "^10.31.0", - "@carbon/elements": "^10.48.0", + "@carbon/elements": "^10.49.0-rc.0", "@carbon/icons-handlebars": "^10.44.0", "@carbon/icons-react": "^10.44.0", "@carbon/test-utils": "^10.20.0", diff --git a/packages/elements/package.json b/packages/elements/package.json index d64eb14eeda8..14f8bf4e238b 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -1,7 +1,7 @@ { "name": "@carbon/elements", "description": "A collection of design elements in code for the IBM Design Language", - "version": "10.48.0", + "version": "10.49.0-rc.0", "license": "Apache-2.0", "main": "lib/index.js", "module": "es/index.js", @@ -41,8 +41,8 @@ "@carbon/import-once": "^10.6.0", "@carbon/layout": "^10.34.0", "@carbon/motion": "^10.26.0", - "@carbon/themes": "^10.47.0", - "@carbon/type": "^10.38.0" + "@carbon/themes": "^10.48.0-rc.0", + "@carbon/type": "^10.39.0-rc.0" }, "devDependencies": { "@carbon/cli": "^10.31.0", diff --git a/packages/react/package.json b/packages/react/package.json index 8225ec564756..9727c21b9a80 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,7 +1,7 @@ { "name": "carbon-components-react", "description": "The Carbon Design System is IBM’s open-source design system for products and experiences.", - "version": "7.49.0", + "version": "7.50.0-rc.0", "license": "Apache-2.0", "main": "lib/index.js", "module": "es/index.js", @@ -102,7 +102,7 @@ "babel-plugin-react-docgen": "^4.2.1", "babel-plugin-transform-inline-environment-variables": "^0.4.3", "browserslist-config-carbon": "^10.6.1", - "carbon-components": "^10.49.0", + "carbon-components": "^10.50.0-rc.0", "carbon-icons": "^7.0.5", "chalk": "^4.1.1", "cli-table": "^0.3.0", diff --git a/packages/sketch/package.json b/packages/sketch/package.json index caa8bcc78dc4..6ffd60690f65 100644 --- a/packages/sketch/package.json +++ b/packages/sketch/package.json @@ -2,7 +2,7 @@ "name": "@carbon/sketch", "private": true, "description": "Tooling for generating a sketch plugin to bring code to design", - "version": "10.47.0", + "version": "10.48.0-rc.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -31,8 +31,8 @@ "@carbon/colors": "^10.34.0", "@carbon/icon-helpers": "^10.25.0", "@carbon/icons": "^10.44.0", - "@carbon/themes": "^10.47.0", - "@carbon/type": "^10.38.0", + "@carbon/themes": "^10.48.0-rc.0", + "@carbon/type": "^10.39.0-rc.0", "@skpm/builder": "^0.7.0", "color-string": "^1.5.3", "core-js": "^3.16.0", diff --git a/packages/styles/package.json b/packages/styles/package.json index 056c271432c6..b9c9fa0494ca 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,7 +1,7 @@ { "name": "@carbon/styles", "description": "Styles for the Carbon Design System", - "version": "0.10.0", + "version": "0.11.0-rc.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -25,8 +25,8 @@ "@carbon/grid": "^10.39.0", "@carbon/layout": "^10.34.0", "@carbon/motion": "^10.26.0", - "@carbon/themes": "^10.47.0", - "@carbon/type": "^10.38.0", + "@carbon/themes": "^10.48.0-rc.0", + "@carbon/type": "^10.39.0-rc.0", "@ibm/plex": "6.0.0-next.6" }, "devDependencies": { diff --git a/packages/themes/package.json b/packages/themes/package.json index ec8a69439cc9..9960eb698ee1 100644 --- a/packages/themes/package.json +++ b/packages/themes/package.json @@ -1,7 +1,7 @@ { "name": "@carbon/themes", "description": "Themes for applying color in the Carbon Design System", - "version": "10.47.0", + "version": "10.48.0-rc.0", "license": "Apache-2.0", "main": "lib/index.js", "module": "es/index.js", @@ -31,7 +31,7 @@ "dependencies": { "@carbon/colors": "^10.34.0", "@carbon/layout": "^10.34.0", - "@carbon/type": "^10.38.0", + "@carbon/type": "^10.39.0-rc.0", "color": "^3.1.2" }, "devDependencies": { diff --git a/packages/type/package.json b/packages/type/package.json index 690c7c995afd..faea746f161b 100644 --- a/packages/type/package.json +++ b/packages/type/package.json @@ -1,7 +1,7 @@ { "name": "@carbon/type", "description": "Typography for digital and software products using the Carbon Design System", - "version": "10.38.0", + "version": "10.39.0-rc.0", "license": "Apache-2.0", "main": "lib/index.js", "module": "es/index.js", diff --git a/www/package.json b/www/package.json index f0cc01198b44..c9071b3b1099 100644 --- a/www/package.json +++ b/www/package.json @@ -1,7 +1,7 @@ { "name": "www", "private": true, - "version": "0.1.0", + "version": "0.2.0-rc.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -22,7 +22,7 @@ "start": "next start" }, "dependencies": { - "@carbon/react": "^0.10.0", + "@carbon/react": "^0.11.0-rc.0", "@octokit/core": "^3.5.1", "@octokit/plugin-retry": "^3.0.9", "@octokit/plugin-throttling": "^3.5.2", diff --git a/yarn.lock b/yarn.lock index 641d359dadab..a4e13cab53d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1768,7 +1768,7 @@ __metadata: languageName: unknown linkType: soft -"@carbon/elements@^10.48.0, @carbon/elements@workspace:packages/elements": +"@carbon/elements@^10.49.0-rc.0, @carbon/elements@workspace:packages/elements": version: 0.0.0-use.local resolution: "@carbon/elements@workspace:packages/elements" dependencies: @@ -1779,8 +1779,8 @@ __metadata: "@carbon/import-once": ^10.6.0 "@carbon/layout": ^10.34.0 "@carbon/motion": ^10.26.0 - "@carbon/themes": ^10.47.0 - "@carbon/type": ^10.38.0 + "@carbon/themes": ^10.48.0-rc.0 + "@carbon/type": ^10.39.0-rc.0 fs-extra: ^8.1.0 klaw-sync: ^6.0.0 replace-in-file: ^3.4.2 @@ -1984,7 +1984,7 @@ __metadata: languageName: unknown linkType: soft -"@carbon/react@^0.10.0, @carbon/react@workspace:packages/carbon-react": +"@carbon/react@^0.11.0-rc.0, @carbon/react@workspace:packages/carbon-react": version: 0.0.0-use.local resolution: "@carbon/react@workspace:packages/carbon-react" dependencies: @@ -1997,9 +1997,9 @@ __metadata: "@babel/preset-react": ^7.14.5 "@carbon/feature-flags": ^0.6.0 "@carbon/icons-react": ^10.44.0 - "@carbon/styles": ^0.10.0 + "@carbon/styles": ^0.11.0-rc.0 "@carbon/telemetry": 0.0.0-alpha.6 - "@carbon/themes": ^10.47.0 + "@carbon/themes": ^10.48.0-rc.0 "@rollup/plugin-babel": ^5.3.0 "@rollup/plugin-commonjs": ^18.0.0 "@rollup/plugin-node-resolve": ^11.2.1 @@ -2013,8 +2013,8 @@ __metadata: babel-plugin-dev-expression: ^0.2.2 babel-preset-carbon: ^0.1.0 browserslist-config-carbon: ^10.6.1 - carbon-components: ^10.49.0 - carbon-components-react: ^7.49.0 + carbon-components: ^10.50.0-rc.0 + carbon-components-react: ^7.50.0-rc.0 carbon-icons: ^7.0.7 css-loader: ^5.2.4 fs-extra: ^10.0.0 @@ -2055,8 +2055,8 @@ __metadata: "@carbon/colors": ^10.34.0 "@carbon/icon-helpers": ^10.25.0 "@carbon/icons": ^10.44.0 - "@carbon/themes": ^10.47.0 - "@carbon/type": ^10.38.0 + "@carbon/themes": ^10.48.0-rc.0 + "@carbon/type": ^10.39.0-rc.0 "@skpm/builder": ^0.7.0 color-string: ^1.5.3 core-js: ^3.16.0 @@ -2067,7 +2067,7 @@ __metadata: languageName: unknown linkType: soft -"@carbon/styles@^0.10.0, @carbon/styles@workspace:packages/styles": +"@carbon/styles@^0.11.0-rc.0, @carbon/styles@workspace:packages/styles": version: 0.0.0-use.local resolution: "@carbon/styles@workspace:packages/styles" dependencies: @@ -2077,8 +2077,8 @@ __metadata: "@carbon/layout": ^10.34.0 "@carbon/motion": ^10.26.0 "@carbon/test-utils": ^10.20.0 - "@carbon/themes": ^10.47.0 - "@carbon/type": ^10.38.0 + "@carbon/themes": ^10.48.0-rc.0 + "@carbon/type": ^10.39.0-rc.0 "@ibm/plex": 6.0.0-next.6 css: ^3.0.0 languageName: unknown @@ -2121,7 +2121,7 @@ __metadata: languageName: unknown linkType: soft -"@carbon/themes@^10.47.0, @carbon/themes@workspace:packages/themes": +"@carbon/themes@^10.48.0-rc.0, @carbon/themes@workspace:packages/themes": version: 0.0.0-use.local resolution: "@carbon/themes@workspace:packages/themes" dependencies: @@ -2133,7 +2133,7 @@ __metadata: "@carbon/layout": ^10.34.0 "@carbon/scss-generator": ^10.13.0 "@carbon/test-utils": ^10.20.0 - "@carbon/type": ^10.38.0 + "@carbon/type": ^10.39.0-rc.0 change-case: ^4.1.1 color: ^3.1.2 core-js: ^3.16.0 @@ -2144,7 +2144,7 @@ __metadata: languageName: unknown linkType: soft -"@carbon/type@^10.38.0, @carbon/type@workspace:packages/type": +"@carbon/type@^10.39.0-rc.0, @carbon/type@workspace:packages/type": version: 0.0.0-use.local resolution: "@carbon/type@workspace:packages/type" dependencies: @@ -10570,7 +10570,7 @@ __metadata: languageName: node linkType: hard -"carbon-components-react@^7.49.0, carbon-components-react@workspace:packages/react": +"carbon-components-react@^7.50.0-rc.0, carbon-components-react@workspace:packages/react": version: 0.0.0-use.local resolution: "carbon-components-react@workspace:packages/react" dependencies: @@ -10615,7 +10615,7 @@ __metadata: babel-plugin-react-docgen: ^4.2.1 babel-plugin-transform-inline-environment-variables: ^0.4.3 browserslist-config-carbon: ^10.6.1 - carbon-components: ^10.49.0 + carbon-components: ^10.50.0-rc.0 carbon-icons: ^7.0.5 chalk: ^4.1.1 classnames: 2.3.1 @@ -10700,7 +10700,7 @@ __metadata: languageName: node linkType: hard -"carbon-components@^10.49.0, carbon-components@workspace:packages/components": +"carbon-components@^10.50.0-rc.0, carbon-components@workspace:packages/components": version: 0.0.0-use.local resolution: "carbon-components@workspace:packages/components" dependencies: @@ -10713,7 +10713,7 @@ __metadata: "@babel/preset-react": ^7.14.5 "@babel/runtime": ^7.14.6 "@carbon/cli": ^10.31.0 - "@carbon/elements": ^10.48.0 + "@carbon/elements": ^10.49.0-rc.0 "@carbon/icons-handlebars": ^10.44.0 "@carbon/icons-react": ^10.44.0 "@carbon/telemetry": 0.0.0-alpha.6 @@ -36131,7 +36131,7 @@ resolve@^2.0.0-next.3: version: 0.0.0-use.local resolution: "www@workspace:www" dependencies: - "@carbon/react": ^0.10.0 + "@carbon/react": ^0.11.0-rc.0 "@octokit/core": ^3.5.1 "@octokit/plugin-retry": ^3.0.9 "@octokit/plugin-throttling": ^3.5.2 From ab061bd84a3fa11912435f7ade83c5d2e2ad03d3 Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Tue, 7 Dec 2021 15:54:04 -0600 Subject: [PATCH 5/5] feat(react): update Tabs API and delete `next/Tab` (#10225) * chore: check in progress * chore: check in progress * chore: check in progress * refactor(styles): remove unused tabs styles * fix(styles): remove tabs scrollable class and replace with tabs * chore(react): remove outdated comments * chore(react): remove outdated comments * chore(react): remove outdated comments * chore: check in progress * feat(react): update tabs api and delete tab next * fix(react): remove unnecessary ContainedTab export * fix(react): remove contained tab export * fix(react): update snapshots and add tests to new Tabs * feat(react): remove unstable containedTabs --- .../__snapshots__/PublicAPI-test.js.snap | 103 +-- packages/react/src/__tests__/index-test.js | 5 +- packages/react/src/components/Tab/index.js | 5 +- .../src/components/Tab/next/ContainedTab.js | 155 ---- .../react/src/components/Tab/next/Tab-test.js | 204 ----- packages/react/src/components/Tab/next/Tab.js | 155 ---- .../react/src/components/Tabs/Tabs-story.js | 2 +- packages/react/src/components/Tabs/index.js | 5 +- .../src/components/Tabs/next/ContainedTabs.js | 601 ------------- .../src/components/Tabs/next/Tabs-test.js | 320 +++---- .../src/components/Tabs/next/Tabs.Skeleton.js | 6 +- .../react/src/components/Tabs/next/Tabs.js | 817 +++++++----------- .../src/components/Tabs/next/Tabs.stories.js | 106 ++- packages/react/src/index.js | 7 +- .../src/internal/useNoInteractiveChildren.js | 2 +- .../styles/scss/components/tabs/_tabs.scss | 189 ++-- 16 files changed, 630 insertions(+), 2052 deletions(-) delete mode 100644 packages/react/src/components/Tab/next/ContainedTab.js delete mode 100644 packages/react/src/components/Tab/next/Tab-test.js delete mode 100644 packages/react/src/components/Tab/next/Tab.js delete mode 100644 packages/react/src/components/Tabs/next/ContainedTabs.js diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index a8df06ae14a5..232d90310ec7 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -5942,51 +5942,6 @@ Map { }, }, }, - "unstable_ContainedTab" => Object { - "$$typeof": Symbol(react.forward_ref), - "propTypes": Object { - "className": Object { - "type": "string", - }, - "disabled": Object { - "type": "bool", - }, - "handleTabClick": Object { - "type": "func", - }, - "handleTabKeyDown": Object { - "type": "func", - }, - "id": Object { - "type": "string", - }, - "index": Object { - "type": "number", - }, - "label": Object { - "type": "node", - }, - "onClick": Object { - "type": "func", - }, - "onKeyDown": Object { - "type": "func", - }, - "renderButton": Object { - "type": "func", - }, - "renderContent": Object { - "type": "func", - }, - "selected": Object { - "type": "bool", - }, - "tabIndex": Object { - "type": "number", - }, - }, - "render": [Function], - }, "TabContent" => Object { "defaultProps": Object { "selected": false, @@ -6085,7 +6040,7 @@ Map { }, }, }, - "unstable_ContainedTabs" => Object { + "unstable_TabPanel" => Object { "$$typeof": Symbol(react.forward_ref), "propTypes": Object { "children": Object { @@ -6094,32 +6049,19 @@ Map { "className": Object { "type": "string", }, - "hidden": Object { - "type": "bool", - }, - "leftOverflowButtonProps": Object { - "type": "object", - }, - "light": [Function], - "onClick": Object { - "type": "func", - }, - "onKeyDown": Object { - "type": "func", - }, - "onSelectionChange": Object { - "type": "func", - }, - "rightOverflowButtonProps": Object { - "type": "object", - }, - "scrollIntoView": Object { - "type": "bool", - }, - "selected": Object { - "type": "number", + }, + "render": [Function], + }, + "unstable_TabPanels" => Object { + "propTypes": Object { + "children": Object { + "type": "node", }, - "selectionMode": Object { + }, + }, + "unstable_TabList" => Object { + "propTypes": Object { + "activation": Object { "args": Array [ Array [ "automatic", @@ -6128,11 +6070,26 @@ Map { ], "type": "oneOf", }, - "tabContentClassName": Object { + "aria-label": Object { + "isRequired": true, + "type": "string", + }, + "children": Object { + "type": "node", + }, + "className": Object { "type": "string", }, + "contained": Object { + "type": "bool", + }, + "light": Object { + "type": "bool", + }, + "scrollIntoView": Object { + "type": "bool", + }, }, - "render": [Function], }, "Tag" => Object { "propTypes": Object { diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js index f9f725768662..1bd44ec9f69b 100644 --- a/packages/react/src/__tests__/index-test.js +++ b/packages/react/src/__tests__/index-test.js @@ -199,8 +199,6 @@ Array [ "TooltipDefinition", "TooltipIcon", "UnorderedList", - "unstable_ContainedTab", - "unstable_ContainedTabs", "unstable_FeatureFlags", "unstable_HStack", "unstable_Heading", @@ -219,6 +217,9 @@ Array [ "unstable_ProgressBar", "unstable_Section", "unstable_Stack", + "unstable_TabList", + "unstable_TabPanel", + "unstable_TabPanels", "unstable_Theme", "unstable_Tooltip", "unstable_TreeNode", diff --git a/packages/react/src/components/Tab/index.js b/packages/react/src/components/Tab/index.js index 19d47aeda570..6e0e9f938ad1 100644 --- a/packages/react/src/components/Tab/index.js +++ b/packages/react/src/components/Tab/index.js @@ -6,12 +6,9 @@ */ import * as FeatureFlags from '@carbon/feature-flags'; -import { default as TabNext } from './next/Tab'; +import { Tab as TabNext } from '../Tabs/next/Tabs'; import { default as TabClassic } from './Tab'; -import ContainedTab from './next/ContainedTab'; const Tab = FeatureFlags.enabled('enable-v11-release') ? TabNext : TabClassic; -export { ContainedTab }; - export default Tab; diff --git a/packages/react/src/components/Tab/next/ContainedTab.js b/packages/react/src/components/Tab/next/ContainedTab.js deleted file mode 100644 index 8e444f629a74..000000000000 --- a/packages/react/src/components/Tab/next/ContainedTab.js +++ /dev/null @@ -1,155 +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 from 'react'; -import classNames from 'classnames'; -import { usePrefix } from '../../../internal/usePrefix'; - -const ContainedTab = React.forwardRef(function ContainedTab( - { - className, - disabled, - handleTabClick, - handleTabKeyDown, - id, - index, - label = 'provide a label', - onClick = () => {}, - onKeyDown = () => {}, - renderButton, - renderContent, // eslint-disable-line no-unused-vars - selected = false, - tabIndex = 0, - ...other - }, - ref -) { - const prefix = usePrefix(); - - const classes = classNames(className, `${prefix}--tabs__nav-item`, { - [`${prefix}--tabs__nav-item--disabled`]: disabled, - [`${prefix}--tabs__nav-item--selected`]: selected, - }); - - const buttonProps = { - ['aria-selected']: selected, - ['aria-disabled']: disabled, - ['aria-controls']: id && `${id}__panel`, - id, - className: `${prefix}--tabs__nav-link`, - tabIndex: !disabled ? tabIndex : -1, - ref: ref, - }; - - return ( -
  • { - if (disabled) { - return; - } - if (handleTabClick) { - handleTabClick(index, evt); - } - onClick(evt); - }} - onKeyDown={(evt) => { - if (disabled) { - return; - } - if (handleTabKeyDown) { - handleTabKeyDown(index, evt); - } - onKeyDown(evt); - }} - role="presentation"> - {renderButton ? ( - renderButton(buttonProps) - ) : ( - - )} -
  • - ); -}); - -ContainedTab.propTypes = { - /** - * Specify an optional className to be added to your Tab - */ - className: PropTypes.string, - - /** - * Whether your Tab is disabled. - */ - disabled: PropTypes.bool, - - /** - * A handler that is invoked when a user clicks on the control. - * Reserved for usage in Tabs - */ - handleTabClick: PropTypes.func, - - /** - * A handler that is invoked on the key down event for the control. - * Reserved for usage in Tabs - */ - handleTabKeyDown: PropTypes.func, - - /** - * The element ID for the top-level element. - */ - id: PropTypes.string, - - /** - * The index of your Tab in your Tabs. Reserved for usage in Tabs - */ - index: PropTypes.number, - - /** - * Provide the contents of your Tab - */ - label: PropTypes.node, - - /** - * Provide a handler that is invoked when a user clicks on the control - */ - onClick: PropTypes.func, - - /** - * Provide a handler that is invoked on the key down event for the control - */ - onKeyDown: PropTypes.func, - - /* - * An optional parameter to allow overriding the anchor rendering. - * Useful for using Tab along with react-router or other client - * side router libraries. - **/ - renderButton: PropTypes.func, - - /* - * An optional parameter to allow overriding the content rendering. - **/ - renderContent: PropTypes.func, - - /** - * Whether your Tab is selected. - * Reserved for usage in Tabs - */ - selected: PropTypes.bool, - - /** - * Specify the tab index of the ` - )} - - ); -}); - -Tab.propTypes = { - /** - * Specify an optional className to be added to your Tab - */ - className: PropTypes.string, - - /** - * Whether your Tab is disabled. - */ - disabled: PropTypes.bool, - - /** - * A handler that is invoked when a user clicks on the control. - * Reserved for usage in Tabs - */ - handleTabClick: PropTypes.func, - - /** - * A handler that is invoked on the key down event for the control. - * Reserved for usage in Tabs - */ - handleTabKeyDown: PropTypes.func, - - /** - * The element ID for the top-level element. - */ - id: PropTypes.string, - - /** - * The index of your Tab in your Tabs. Reserved for usage in Tabs - */ - index: PropTypes.number, - - /** - * Provide the contents of your Tab - */ - label: PropTypes.node, - - /** - * Provide a handler that is invoked when a user clicks on the control - */ - onClick: PropTypes.func, - - /** - * Provide a handler that is invoked on the key down event for the control - */ - onKeyDown: PropTypes.func, - - /* - * An optional parameter to allow overriding the anchor rendering. - * Useful for using Tab along with react-router or other client - * side router libraries. - **/ - renderButton: PropTypes.func, - - /* - * An optional parameter to allow overriding the content rendering. - **/ - renderContent: PropTypes.func, - - /** - * Whether your Tab is selected. - * Reserved for usage in Tabs - */ - selected: PropTypes.bool, - - /** - * Specify the tab index of the ` - {!leftOverflowNavButtonHidden && ( -
    - )} -
      - {tabsWithProps} -
    - {!rightOverflowNavButtonHidden && ( -
    - )} - -
    - {tabContentWithProps} - - ); -}); - -ContainedTabs.propTypes = { - /** - * Pass in a collection of children to be rendered depending on the - * currently selected tab - */ - children: PropTypes.node, - - /** - * Provide a className that is applied to the root
    component for the - * - */ - className: PropTypes.string, - - /** - * Specify whether the Tab content is hidden - */ - hidden: PropTypes.bool, - - /** - * Provide the props that describe the left overflow button - */ - leftOverflowButtonProps: PropTypes.object, - - /** - * Specify whether or not to use the light component variant - */ - light: deprecate( - PropTypes.bool, - 'The light prop has been deprecated in v11 in favor of our new layering model that uses the Layer component' - ), - - /** - * Optionally provide an `onClick` handler that is invoked when a is - * clicked - */ - onClick: PropTypes.func, - - /** - * Optionally provide an `onKeyDown` handler that is invoked when keyed - * navigation is triggered - */ - onKeyDown: PropTypes.func, - - /** - * Provide an optional handler that is called whenever the selection - * changes. This method is called with the index of the tab that was - * selected - */ - onSelectionChange: PropTypes.func, - - /** - * Provide the props that describe the right overflow button - */ - rightOverflowButtonProps: PropTypes.object, - - /** - * Choose whether or not to automatically scroll to newly selected tabs - * on component rerender - */ - scrollIntoView: PropTypes.bool, - - /** - * Optionally provide an index for the currently selected - */ - selected: PropTypes.number, - - /** - * Choose whether or not to automatically change selection on focus - */ - selectionMode: PropTypes.oneOf(['automatic', 'manual']), - - /** - * Provide a className that is applied to the components - */ - tabContentClassName: PropTypes.string, - - // /** - // * Provide the type of Tab - // */ - // type: PropTypes.oneOf(['default', 'container']), -}; - -export default ContainedTabs; diff --git a/packages/react/src/components/Tabs/next/Tabs-test.js b/packages/react/src/components/Tabs/next/Tabs-test.js index c95e6731711a..83566e243f22 100644 --- a/packages/react/src/components/Tabs/next/Tabs-test.js +++ b/packages/react/src/components/Tabs/next/Tabs-test.js @@ -1,264 +1,178 @@ import React from 'react'; -import { default as Tabs } from './Tabs'; -import { default as Tab } from '../../Tab/next/Tab'; +import { Tabs, Tab, TabPanel, TabPanels, TabList } from './Tabs'; import { render, screen } from '@testing-library/react'; -import { fireEvent } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; describe('Tabs', () => { - it('adds extra classes that are passed via className prop', async () => { + it('should update selected index based on the default provided', () => { render( - - content1 - content2 + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const tabs = screen.getByTestId('tabs-test'); - await expect(tabs.classList.contains('custom-class')).toBe(true); - }); - - it('renders
      with tablist role by default', async () => { - render( - - content1 - content2 - - ); - - const tablist = screen.getByRole('tablist'); - await expect(tablist).toBeTruthy(); - }); -}); - -describe('Children tabs', () => { - it('renders children', async () => { - render( - - content1 - content2 - + expect(screen.getByText('Tab Label 2')).toHaveAttribute( + 'aria-selected', + 'true' ); - - const tabArray = screen.getAllByRole('presentation'); - await expect(tabArray.length).toEqual(2); }); - it('first tab is selected by default', async () => { + it('should set a className from props on outermost element in TabList', () => { render( - - - content1 - - content2 - - ); - - const firstTab = screen.getByTestId('first-tab'); - await expect( - firstTab.classList.contains('bx--tabs__nav-item--selected') - ).toBe(true); - }); - - it('overrides default selected tab when selected prop is provided', async () => { - render( - - - content1 - - - content2 - + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const firstTab = screen.getByTestId('first-tab'); - const secondTab = screen.getByTestId('second-tab'); - - await expect( - firstTab.classList.contains('bx--tabs__nav-item--selected') - ).toBe(false); - await expect( - secondTab.classList.contains('bx--tabs__nav-item--selected') - ).toBe(true); + expect(screen.getByRole('tablist')).toHaveClass('custom-class'); }); }); -describe('Children tab content', () => { - it('renders correct number of children content as expected', async () => { +describe('Tab', () => { + it('should set a className from props on outermost element in Tab', () => { render( - - content1 - content2 + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const contentArray = screen.getAllByRole('tabpanel', { hidden: true }); - await expect(contentArray.length).toEqual(2); + expect(screen.getByText('Tab Label 2')).toHaveClass('custom-class'); }); - it('only shows one content tabpanel at a time', async () => { + it('should not select a disabled tab and select next tab', () => { render( - - content1 - content2 - content3 + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const contentArray = screen.getAllByRole('tabpanel'); - await expect(contentArray.length).toEqual(1); - }); - - it('adds extra classes that are passed via tabContentClassName prop', async () => { - render( - - content1 - content2 - + expect(screen.getByText('Tab Label 1')).toHaveAttribute( + 'aria-selected', + 'false' ); - const content = screen.getByRole('tabpanel'); - await expect(content.classList.contains('content-class')).toBe(true); - }); - - it('renders unselected tab content with hidden attribute', async () => { - render( - - content1 - content2 - + // By default, if a Tab is disabled, the next Tab should be selected + expect(screen.getByText('Tab Label 2')).toHaveAttribute( + 'aria-selected', + 'true' ); - - const contentArray = screen.getAllByRole('tabpanel', { hidden: true }); - await expect(contentArray[1]).toHaveAttribute('hidden'); }); -}); -describe('Keyboard events', () => { - it('updates selected tab and content, and loops from first tab to last tab when pressing left arrow key', async () => { + it('should provide a custom element to render instead of default button if provided', () => { render( - - content1 - - - content2 - + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - - const tab1 = screen.getByTestId('tab1'); - const tab2 = screen.getByTestId('tab2'); - - const tabContent = screen.getAllByRole('tabpanel'); - const tab1Content = tabContent[0]; - fireEvent.keyDown(tab1, { - key: 'ArrowLeft', - code: 'ArrowLeft', - charCode: 37, - }); - await expect(tab2.classList.contains('bx--tabs__nav-item--selected')).toBe( - true - ); - await expect(tab1Content).toHaveAttribute('hidden'); + expect(screen.getByText('Tab Label 1').tagName).toBe('DIV'); }); - it('updates selected tab and content when pressing right arrow key', async () => { + it('should call onClick from props if provided', () => { + const onClick = jest.fn(); render( - - content1 - - - content2 - + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const tab1 = screen.getByTestId('tab1'); - const tab2 = screen.getByTestId('tab2'); - const tabContent = screen.getAllByRole('tabpanel'); - const tab1Content = tabContent[0]; + userEvent.click(screen.getByText('Tab Label 1')); - fireEvent.keyDown(tab1, { - key: 'ArrowRight', - code: 'ArrowRight', - charCode: 39, - }); - await expect(tab2.classList.contains('bx--tabs__nav-item--selected')).toBe( - true - ); - await expect(tab1Content).toHaveAttribute('hidden'); + expect(onClick).toHaveBeenCalled(); }); - it('ignores disabled tabs', async () => { + it('should call onKeyDown from props if provided', () => { + const onKeyDown = jest.fn(); render( - - content1 - - - content2 - - - content3 - + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const tab1 = screen.getByTestId('tab1'); - const tab3 = screen.getByTestId('tab3'); - fireEvent.keyDown(tab1, { - key: 'ArrowRight', - code: 'ArrowRight', - charCode: 39, - }); - await expect(tab3.classList.contains('bx--tabs__nav-item--selected')).toBe( - true - ); - }); -}); + userEvent.type(screen.getByText('Tab Label 1'), 'enter'); -describe('Click events', () => { - it('updates selected tab and content on click', async () => { - render( - - - content1 - - - content2 - - - ); - const tab2 = screen.getByTestId('tab2'); - const tabContent = screen.getAllByRole('tabpanel'); - const tab1Content = tabContent[0]; - fireEvent.click(tab2); - await expect(tab2.classList.contains('bx--tabs__nav-item--selected')).toBe( - true - ); - await expect(tab1Content).toHaveAttribute('hidden'); + expect(onKeyDown).toHaveBeenCalled(); }); +}); - it('ignores disabled tab on click', async () => { +describe('TabPanel', () => { + it('should have a className if provided by props', () => { render( - - content1 - - - content2 - + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const tab2 = screen.getByTestId('tab2'); - const tabContent = screen.getAllByRole('tabpanel', { hidden: true }); - const tab2Content = tabContent[1]; - fireEvent.click(tab2); - await expect(tab2.classList.contains('bx--tabs__nav-item--selected')).toBe( - false - ); - await expect(tab2Content).toHaveAttribute('hidden'); + expect(screen.getByText('Tab Panel 1')).toHaveClass('custom-class'); }); }); diff --git a/packages/react/src/components/Tabs/next/Tabs.Skeleton.js b/packages/react/src/components/Tabs/next/Tabs.Skeleton.js index b251865d8982..86a56b0065a4 100644 --- a/packages/react/src/components/Tabs/next/Tabs.Skeleton.js +++ b/packages/react/src/components/Tabs/next/Tabs.Skeleton.js @@ -21,10 +21,10 @@ function Tab() { ); } -function TabsSkeleton({ className, type, ...rest }) { +function TabsSkeleton({ className, contained, ...rest }) { const prefix = usePrefix(); const tabClasses = cx(className, `${prefix}--tabs`, `${prefix}--skeleton`, { - [`${prefix}--tabs--container`]: type === 'container', + [`${prefix}--tabs--contained`]: contained, }); return (
      @@ -48,7 +48,7 @@ TabsSkeleton.propTypes = { /** * Provide the type of Tab */ - type: PropTypes.oneOf(['', 'default', 'container']), + contained: PropTypes.bool, }; export default TabsSkeleton; diff --git a/packages/react/src/components/Tabs/next/Tabs.js b/packages/react/src/components/Tabs/next/Tabs.js index 62b989444950..22f10b9f09af 100644 --- a/packages/react/src/components/Tabs/next/Tabs.js +++ b/packages/react/src/components/Tabs/next/Tabs.js @@ -6,586 +6,409 @@ */ import PropTypes from 'prop-types'; -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import classNames from 'classnames'; -import { ChevronLeft16, ChevronRight16 } from '@carbon/icons-react'; -import debounce from 'lodash.debounce'; +import React, { useState, useRef, useEffect } from 'react'; +import cx from 'classnames'; import { keys, match, matches } from '../../../internal/keyboard'; -import TabContent from '../../TabContent'; -import deprecate from '../../../prop-types/deprecate'; import { usePrefix } from '../../../internal/usePrefix'; +import { useId } from '../../../internal/useId'; +import { getInteractiveContent } from '../../../internal/useNoInteractiveChildren'; +import { useControllableState } from '../../ContentSwitcher/next/useControllableState'; +import { useMergedRefs } from '../../../internal/useMergedRefs'; + +// Used to manage the overall state of the Tabs +const TabsContext = React.createContext(); + +// Used to keep track of position in a tablist +const TabContext = React.createContext(); + +// Used to keep track of position in a list of tab panels +const TabPanelContext = React.createContext(); +function Tabs({ + children, + defaultSelectedIndex = 0, + onChange, + selectedIndex: controlledSelectedIndex, +}) { + const baseId = useId('ccs'); + // The active index is used to track the element which has focus in our tablist + const [activeIndex, setActiveIndex] = useState(defaultSelectedIndex); + // The selected index is used for the tab/panel pairing which is "visible" + const [selectedIndex, setSelectedIndex] = useControllableState({ + value: controlledSelectedIndex, + defaultValue: defaultSelectedIndex, + onChange: (value) => { + if (onChange) { + onChange({ selectedIndex: value }); + } + }, + }); -const Tabs = React.forwardRef(function Tabs( - { - children, - className, - leftOverflowButtonProps, - light = false, - onSelectionChange, - rightOverflowButtonProps, - scrollIntoView = true, - selected = 0, - selectionMode = 'automatic', - tabContentClassName, - ...other - }, - ref -) { - const prefix = usePrefix(); - - //refs - const tablist = useRef(); - const leftOverflowNavButton = useRef(); - const rightOverflowNavButton = useRef(); - const tabs = useRef([]); + const value = { + baseId, + activeIndex, + defaultSelectedIndex, + setActiveIndex, + selectedIndex, + setSelectedIndex, + }; - //states - const [horizontalOverflow, setHorizontalOverflow] = useState(false); - const [tablistClientWidth, setTablistClientWidth] = useState(null); - const [tablistScrollWidth, setTablistScrollWidth] = useState(null); - const [tablistScrollLeft, setTablistScrollLeft] = useState(null); - const [isSelected, setIsSelected] = useState(selected); - const [prevSelected, setPrevSelected] = useState(isSelected); + return {children}; +} +Tabs.propTypes = { /** - * prop + state alignment - getDerivedStateFromProps - * only update if selected prop changes + * Provide child elements to be rendered inside of the `Tabs`. + * These elements should render either `TabsList` or `TabsPanels` */ - useEffect(() => { - if (selected !== prevSelected) { - setIsSelected(selected); - setPrevSelected(selected); - } - }, [selected]); //eslint-disable-line react-hooks/exhaustive-deps - - // width of the overflow buttons - let OVERFLOW_BUTTON_OFFSET = 40; + children: PropTypes.node, /** - * `scroll` event handler to save tablist clientWidth, scrollWidth, and - * scrollLeft + * Specify which content tab should be initially selected when the component + * is first rendered */ - const handleScroll = () => { - if (!tablist?.current) { - return; - } - const { clientWidth, scrollLeft, scrollWidth } = tablist.current; - - setTablistClientWidth(clientWidth); - setTablistScrollWidth(scrollWidth); - setTablistScrollLeft(scrollLeft); - setHorizontalOverflow(scrollWidth > clientWidth); - }; + defaultSelectedIndex: PropTypes.number, /** - * The debounced version of the `resize` event handler. - * @type {Function} - * @private + * Provide an optional function which is called whenever the state of the + * `Tabs` changes */ - const _debouncedHandleWindowResize = useRef(); - - const _handleWindowResize = handleScroll; + onChange: PropTypes.func, /** - * returns all tabs that are not disabled - * used for keyboard navigation + * Control which content panel is currently selected. This puts the component + * in a controlled mode and should be used along with `onChange` */ - const getEnabledTabs = () => - React.Children.toArray(children).reduce( - (enabledTabs, tab, index) => - !tab.props.disabled ? enabledTabs.concat(index) : enabledTabs, - [] - ); + selectedIndex: PropTypes.number, +}; - /** - * returns the index of the next tab we are going to when navigating L/R arrow keys (i.e. 0, 1, 2) - * used in handleTabKeyDown to get the next index after keyboard arrow evt, which then updates selected tab - */ - const getNextIndex = (index, direction) => { - const enabledTabs = getEnabledTabs(); - const nextIndex = Math.max( - enabledTabs.indexOf(index) + direction, - // For `tab` not found in `enabledTabs` - -1 - ); - const nextIndexLooped = - nextIndex >= 0 && nextIndex < enabledTabs.length - ? nextIndex - : nextIndex - Math.sign(nextIndex) * enabledTabs.length; - return enabledTabs[nextIndexLooped]; - }; +function useEffectOnce(callback) { + const savedCallback = useRef(callback); + const effectGuard = useRef(false); - /** - * used as second argument for getNextIndex(i,d) - * returns -1, 1 or 0 depending on arrow key - * number is then used in math calculations to find the index of the next tab we are navigating to - */ - const getDirection = (evt) => { - if (match(evt, keys.ArrowLeft)) { - return -1; - } - if (match(evt, keys.ArrowRight)) { - return 1; + useEffect(() => { + savedCallback.current = callback; + }); + + useEffect(() => { + if (effectGuard.current !== true) { + effectGuard.current = true; + savedCallback.current(); } + }, []); +} + +/** + * Get the next index for a given keyboard event given a count of the total + * items and the current index + * @param {Event} event + * @param {number} total + * @param {number} index + * @returns {number} + */ +function getNextIndex(event, total, index) { + if (match(event, keys.ArrowRight)) { + return (index + 1) % total; + } else if (match(event, keys.ArrowLeft)) { + return (total + index - 1) % total; + } else if (match(event, keys.Home)) { return 0; - }; + } else if (match(event, keys.End)) { + return total - 1; + } +} + +function TabList({ + activation = 'automatic', + 'aria-label': label, + children, + className: customClassName, + light, + scrollIntoView, + contained = false, + ...rest +}) { + const { + activeIndex, + selectedIndex, + setSelectedIndex, + setActiveIndex, + } = React.useContext(TabsContext); + const prefix = usePrefix(); + const ref = useRef(null); + const className = cx(`${prefix}--tabs`, customClassName, { + [`${prefix}--tabs--contained`]: contained, + [`${prefix}--tabs--light`]: light, + }); - const getTabAt = useCallback( - (index) => tabs.current[index] || React.Children.toArray(children)[index], - [tabs, children] - ); + const tabs = []; - const scrollTabIntoView = (event, { index }) => { - const tab = getTabAt(index); + function onKeyDown(event) { if ( - matches(event, [keys.ArrowLeft, keys.ArrowRight]) || - event.type === 'click' + matches(event, [keys.ArrowRight, keys.ArrowLeft, keys.Home, keys.End]) ) { - const currentScrollLeft = tablistScrollLeft; - tab?.tabAnchor?.scrollIntoView({ - block: 'nearest', - inline: 'nearest', + const activeTabs = tabs.filter((tab) => { + return !tab.current.disabled; }); - tab?.tabAnchor?.focus(); - const newScrollLeft = tablist.current.scrollLeft; - if (newScrollLeft > currentScrollLeft) { - tablist.current.scrollLeft += OVERFLOW_BUTTON_OFFSET; + + const currentIndex = activeTabs.indexOf( + tabs[activation === 'automatic' ? selectedIndex : activeIndex] + ); + const nextIndex = tabs.indexOf( + activeTabs[getNextIndex(event, activeTabs.length, currentIndex)] + ); + + if (activation === 'automatic') { + setSelectedIndex(nextIndex); + } else if (activation === 'manual') { + setActiveIndex(nextIndex); } + + tabs[nextIndex].current.focus(); } - }; + } - /** - * selecting tab on click and on keyboard nav - * index = tab to be selected, returned in handleTabKeyDown - * onSelectionChange = optional prop for event handler - */ - const selectTabAt = (event, { index, onSelectionChange }) => { - scrollTabIntoView(event, { index }); - if (isSelected !== index) { - setIsSelected(index); - setPrevSelected(index); - if (typeof onSelectionChange === 'function') { - onSelectionChange(index); - } + useEffectOnce(() => { + const tab = tabs[selectedIndex]; + if (scrollIntoView && tab) { + tab.current.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); } - }; + }); - /** - * keyboard event handler - */ - const handleTabKeyDown = (onSelectionChange) => { - return (index, evt) => { - if (matches(evt, [keys.Enter, keys.Space])) { - selectTabAt(evt, { index, onSelectionChange }); - } + useEffectOnce(() => { + if (tabs[selectedIndex].current.disabled) { + const activeTabs = tabs.filter((tab) => { + return !tab.current.disabled; + }); - const nextIndex = (() => { - if (matches(evt, [keys.ArrowLeft, keys.ArrowRight])) { - return getNextIndex(index, getDirection(evt)); - } - if (match(evt, keys.Home)) { - return 0; - } - if (match(evt, keys.End)) { - return getEnabledTabs().pop(); - } - })(); - const tab = getTabAt(nextIndex); - - // updating selected tab - if ( - matches(evt, [keys.ArrowLeft, keys.ArrowRight, keys.Home, keys.End]) - ) { - evt.preventDefault(); - if (selectionMode !== 'manual') { - selectTabAt(evt, { index: nextIndex, onSelectionChange }); - } else { - scrollTabIntoView(evt, { index: nextIndex }); - } - tab?.focus(); + if (activeTabs.length > 0) { + const tab = activeTabs[0]; + setSelectedIndex(tabs.indexOf(tab)); } - }; - }; + } + }); - const getTabs = () => React.Children.map(children, (tab) => tab); + return ( + // eslint-disable-next-line jsx-a11y/interactive-supports-focus +
      + {React.Children.map(children, (child, index) => { + const ref = React.createRef(); + tabs.push(ref); + return ( + + {React.cloneElement(child, { + ref, + })} + + ); + })} +
      + ); +} +TabList.propTypes = { /** - * click handler - * passed down to Tab children as a prop in `tabsWithProps` - * following functions (handle*) are Props on Tab.js, see Tab.js for parameters + * Specify whether the content tab should be activated automatically or + * manually */ - const handleTabClick = (onSelectionChange) => (index, evt) => { - evt.preventDefault(); - selectTabAt(evt, { index, onSelectionChange }); - }; + activation: PropTypes.oneOf(['automatic', 'manual']), /** - * creates an array of all the child tab items + * Provide an accessible label to be read when a user interacts with this + * component */ - const setTabAt = (index, tabRef) => { - tabs.current[index] = tabRef; - }; - - let overflowNavInterval = null; + 'aria-label': PropTypes.string.isRequired, /** - * group - overflow scroll - * scrolling via overflow btn click - * click handler for scrollable tabs L/R arrow buttons + * Provide child elements to be rendered inside of `ContentTabs`. + * These elements should render a `ContentTab` */ - const handleOverflowNavClick = (_, { direction, multiplier = 10 }) => { - // account for overflow button appearing and causing tablist width change - const { clientWidth, scrollLeft, scrollWidth } = tablist?.current; - if (direction === 1 && !scrollLeft) { - tablist.current.scrollLeft += OVERFLOW_BUTTON_OFFSET; - } - - tablist.current.scrollLeft += direction * multiplier; - - const leftEdgeReached = - direction === -1 && scrollLeft < OVERFLOW_BUTTON_OFFSET; - - const rightEdgeReached = - direction === 1 && - scrollLeft + clientWidth >= scrollWidth - OVERFLOW_BUTTON_OFFSET; - - if (leftEdgeReached || rightEdgeReached) { - if (leftEdgeReached) { - rightOverflowNavButton?.current?.focus(); - } - if (rightEdgeReached) { - leftOverflowNavButton?.current?.focus(); - } - } - }; + children: PropTypes.node, /** - * group - overflow scroll - * scrolling w/ mouse event - * mousedown handler for scrollable tabs + * Specify an optional className to be added to the container node */ - const handleOverflowNavMouseDown = (event, { direction }) => { - // disregard mouse buttons aside from LMB - if (event.buttons !== 1) { - return; - } - - overflowNavInterval = setInterval(() => { - const { clientWidth, scrollLeft, scrollWidth } = tablist?.current; - - // clear interval if scroll reaches left or right edge - const leftEdgeReached = - direction === -1 && scrollLeft < OVERFLOW_BUTTON_OFFSET; - - const rightEdgeReached = - direction === 1 && - scrollLeft + clientWidth >= scrollWidth - OVERFLOW_BUTTON_OFFSET; - - if (leftEdgeReached || rightEdgeReached) { - clearInterval(overflowNavInterval); - } - - // account for overflow button appearing and causing tablist width change - handleOverflowNavClick(event, { direction }); - }); - }; - + className: PropTypes.string, /** - * group - overflow scroll - * scrolling w/ mouse event - * mouseup handler for scrollable tabs + * Specify whether component is contained type */ - const handleOverflowNavMouseUp = () => { - clearInterval(overflowNavInterval); - }; + contained: PropTypes.bool, /** - * only run once - component did mount equivalent + * Specify whether or not to use the light component variant */ - useEffect(() => { - _debouncedHandleWindowResize.current = debounce(_handleWindowResize, 200); - - _handleWindowResize(); - window.addEventListener('resize', _debouncedHandleWindowResize.current); - - // scroll selected tab into view on mount - const { clientWidth, scrollLeft, scrollWidth } = tablist?.current || {}; - - setTablistClientWidth(clientWidth); - setTablistScrollWidth(scrollWidth); - setTablistScrollLeft(scrollLeft); - - const tab = getTabAt(isSelected); - const horizontalOverflow = scrollWidth > clientWidth; - - if (horizontalOverflow) { - const leftOverflowNavButtonHidden = - tab?.tabAnchor?.getBoundingClientRect().right < - tab?.tabAnchor?.offsetParent.getBoundingClientRect().right; - - const rightOverflowNavButtonHidden = - scrollLeft + clientWidth === scrollWidth; - scrollIntoView && - tab?.tabAnchor?.scrollIntoView({ - block: 'nearest', - inline: 'nearest', - }); - - // account for overflow buttons in scroll position on mount - if (!leftOverflowNavButtonHidden && !rightOverflowNavButtonHidden) { - tablist.current.scrollLeft += OVERFLOW_BUTTON_OFFSET * 2; - } - } - - //component will unmount equivalent - return () => { - if (_debouncedHandleWindowResize.current) { - _debouncedHandleWindowResize.current.cancel(); - } - window.removeEventListener( - 'resize', - _debouncedHandleWindowResize.current - ); - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - + light: PropTypes.bool, /** - * component did update equivalent + * Choose whether or not to automatically scroll to newly selected tabs + * on component rerender */ - useEffect(() => { - // compare current tablist properties to current state - const { - clientWidth: currentTablistClientWidth, - scrollLeft: currentTablistScrollLeft, - scrollWidth: currentTablistScrollWidth, - } = tablist.current; - - if ( - currentTablistClientWidth !== tablistClientWidth || - currentTablistScrollLeft !== tablistScrollLeft || - currentTablistScrollWidth !== tablistScrollWidth - ) { - setTablistClientWidth(currentTablistClientWidth); - setTablistScrollWidth(currentTablistScrollWidth); - setTablistScrollLeft(currentTablistScrollLeft); - setHorizontalOverflow( - currentTablistScrollWidth > currentTablistClientWidth - ); - } + scrollIntoView: PropTypes.bool, +}; - if (scrollIntoView && prevSelected !== isSelected) { - getTabAt(isSelected)?.tabAnchor?.scrollIntoView({ - block: 'nearest', - inline: 'nearest', - }); +const Tab = React.forwardRef(function Tab( + { + as: BaseComponent = 'button', + children, + className: customClassName, + disabled, + onClick, + onKeyDown, + ...rest + }, + ref +) { + const prefix = usePrefix(); + const { selectedIndex, setSelectedIndex, baseId } = React.useContext( + TabsContext + ); + const index = React.useContext(TabContext); + const id = `${baseId}-tab-${index}`; + const panelId = `${baseId}-tabpanel-${index}`; + const className = cx( + `${prefix}--tabs__nav-item`, + `${prefix}--tabs__nav-link`, + customClassName, + { + [`${prefix}--tabs__nav-item--selected`]: selectedIndex === index, + [`${prefix}--tabs__nav-item--disabled`]: disabled, } - }, [ - isSelected, - prevSelected, - scrollIntoView, - tablistClientWidth, - tablistScrollLeft, - tablistScrollWidth, - getTabAt, - ]); - - /** - * The tab panel acts like a tab panel when the screen is wider, but acts - * like a select list when the screen is narrow. In the wide case we want - * to allow the user to use the tab key to set the focus in the tab panel - * and then use the left and right arrow keys to navigate the tabs. In the - * narrow case we want to use the tab key to select different options in - * the list. - * - * We set the tab index based on the different states so the browser will treat - * the whole tab panel as a single focus component when it looks like a tab - * panel and separate components when it looks like a select list. - */ - const tabsWithProps = getTabs().map((tab, index) => { - const tabIndex = index === isSelected ? 0 : -1; - const newTab = React.cloneElement(tab, { - index, - selected: index === isSelected, - handleTabClick: handleTabClick(onSelectionChange), - tabIndex, - ref: (e) => { - setTabAt(index, e); - }, - handleTabKeyDown: handleTabKeyDown(onSelectionChange), - }); - - return newTab; - }); - - const tabContentWithProps = React.Children.map(tabsWithProps, (tab) => { - const { - id: tabId, - children, - selected, - renderContent: Content = TabContent, - } = tab.props; - - return ( - - ); - }); - - const leftOverflowNavButtonHidden = !horizontalOverflow || !tablistScrollLeft; - - const rightOverflowNavButtonHidden = - !horizontalOverflow || - tablistScrollLeft + tablistClientWidth === tablistScrollWidth; - - const classes = { - tabs: classNames(className, `${prefix}--tabs`, { - [`${prefix}--tabs--light`]: light, - }), - tablist: classNames(`${prefix}--tabs__nav`), - leftOverflowButtonClasses: classNames({ - [`${prefix}--tab--overflow-nav-button`]: horizontalOverflow, - [`${prefix}--tab--overflow-nav-button--hidden`]: leftOverflowNavButtonHidden, - }), - rightOverflowButtonClasses: classNames({ - [`${prefix}--tab--overflow-nav-button`]: horizontalOverflow, - [`${prefix}--tab--overflow-nav-button--hidden`]: rightOverflowNavButtonHidden, - }), - }; + ); return ( - <> -
      - - {!leftOverflowNavButtonHidden && ( -
      - )} -
        - {tabsWithProps} -
      - {!rightOverflowNavButtonHidden && ( -
      - )} - -
      - {tabContentWithProps} - + { + if (disabled) { + return; + } + setSelectedIndex(index); + if (onClick) { + onClick(evt); + } + }} + onKeyDown={onKeyDown} + tabIndex={selectedIndex === index ? '0' : '-1'} + type="button"> + {children} + ); }); -Tabs.propTypes = { - /** - * Pass in a collection of children to be rendered depending on the - * currently selected tab - */ - children: PropTypes.node, - +Tab.propTypes = { /** - * Provide a className that is applied to the root
      component for the - * + * Provide a custom element to render instead of the default button */ - className: PropTypes.string, - + as: PropTypes.oneOfType([PropTypes.string, PropTypes.elementType]), /** - * Specify whether the Tab content is hidden + * Provide child elements to be rendered inside of `Tab`. */ - hidden: PropTypes.bool, - + children: PropTypes.node, /** - * Provide the props that describe the left overflow button + * Specify an optional className to be added to your Tab */ - leftOverflowButtonProps: PropTypes.object, - + className: PropTypes.string, /** - * Specify whether or not to use the light component variant + * Whether your Tab is disabled. */ - light: deprecate( - PropTypes.bool, - 'The light prop has been deprecated in v11 in favor of our new layering model that uses the Layer component' - ), - + disabled: PropTypes.bool, /** - * Optionally provide an `onClick` handler that is invoked when a is - * clicked + * Provide a handler that is invoked when a user clicks on the control */ onClick: PropTypes.func, - /** - * Optionally provide an `onKeyDown` handler that is invoked when keyed - * navigation is triggered + * Provide a handler that is invoked on the key down event for the control */ onKeyDown: PropTypes.func, + /* + * An optional parameter to allow overriding the anchor rendering. + * Useful for using Tab along with react-router or other client + * side router libraries. + **/ + renderButton: PropTypes.func, +}; - /** - * Provide an optional handler that is called whenever the selection - * changes. This method is called with the index of the tab that was - * selected - */ - onSelectionChange: PropTypes.func, +const TabPanel = React.forwardRef(function TabPanel( + { children, className: customClassName, ...rest }, + forwardRef +) { + const prefix = usePrefix(); + const panel = useRef(null); + const ref = useMergedRefs([forwardRef, panel]); - /** - * Provide the props that describe the right overflow button - */ - rightOverflowButtonProps: PropTypes.object, + const [tabIndex, setTabIndex] = useState('0'); + const { selectedIndex, baseId } = React.useContext(TabsContext); + const index = React.useContext(TabPanelContext); + const id = `${baseId}-tabpanel-${index}`; + const tabId = `${baseId}-tab-${index}`; + const className = cx(`${prefix}--tab-content`, customClassName); - /** - * Choose whether or not to automatically scroll to newly selected tabs - * on component rerender - */ - scrollIntoView: PropTypes.bool, + // tabindex should only be 0 if no interactive content in children + useEffect(() => { + const interactiveContent = getInteractiveContent(panel.current); + if (interactiveContent) { + setTabIndex('-1'); + } + }, []); + + return ( + + ); +}); +TabPanel.propTypes = { /** - * Optionally provide an index for the currently selected + * Provide child elements to be rendered inside of `TabPanel`. */ - selected: PropTypes.number, - + children: PropTypes.node, /** - * Choose whether or not to automatically change selection on focus + * Specify an optional className to be added to TabPanel. */ - selectionMode: PropTypes.oneOf(['automatic', 'manual']), + className: PropTypes.string, +}; +function TabPanels({ children }) { + return React.Children.map(children, (child, index) => { + return ( + {child} + ); + }); +} + +TabPanels.propTypes = { /** - * Provide a className that is applied to the components + * Provide child elements to be rendered inside of `TabPanels`. */ - tabContentClassName: PropTypes.string, + children: PropTypes.node, }; -export default Tabs; +export { Tabs, Tab, TabPanel, TabPanels, TabList }; + +// TO DO: implement horizontal scroll and the following props: +// leftOverflowButtonProps +// rightOverflowButtonProps diff --git a/packages/react/src/components/Tabs/next/Tabs.stories.js b/packages/react/src/components/Tabs/next/Tabs.stories.js index 33457ffb304e..a5ec931fc9e6 100644 --- a/packages/react/src/components/Tabs/next/Tabs.stories.js +++ b/packages/react/src/components/Tabs/next/Tabs.stories.js @@ -6,13 +6,11 @@ */ import React from 'react'; -import { Button } from 'carbon-components-react'; +import { Tabs, TabList, Tab, TabPanels, TabPanel } from './Tabs'; +import Button from '../../Button'; -import ContainedTab from '../../Tab/next/ContainedTab'; -import ContainedTabs from './ContainedTabs'; import TabsSkeleton from './Tabs.Skeleton'; -import Tab from '../../Tab/next/Tab'; -import Tabs from './Tabs'; +import { Monster20, Corn20, Bat20 } from '@carbon/icons-react'; import { unstable_FeatureFlags as FeatureFlags } from 'carbon-components-react'; @@ -28,57 +26,75 @@ export default { parameters: { component: Tabs, subcomponents: { + TabList, Tab, + TabPanels, + TabPanel, }, }, }; export const Default = () => ( - -

      Content for first tab goes here.

      -
      - -

      Content for second tab goes here.

      - -
      - -

      Content for third tab goes here.

      -
      - -

      Content for fourth tab goes here.

      -
      - Custom Label
      }> -

      Content for fifth tab goes here.

      -
      + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 with a very long long label + Tab Label 5 + + + + Tab Panel 1 + + Tab Panel 2 + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + + +); + +export const IconOnly = () => ( + + + + + + + + + + + + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); export const Contained = () => ( - - -

      Content for first tab goes here.

      -
      - -

      Content for second tab goes here.

      - -
      - -

      Content for third tab goes here.

      -
      - -

      Content for fourth tab goes here.

      -
      - Custom Label
      }> -

      Content for fifth tab goes here.

      - - + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 with a very long long title + Tab Label 5 + + + Tab Panel 1 + + Tab Panel 2 + + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + + ); export const Skeleton = () => { diff --git a/packages/react/src/index.js b/packages/react/src/index.js index d3fba943902d..3f1ca6198c6b 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -239,12 +239,15 @@ export { Stack as unstable_Stack, VStack as unstable_VStack, } from './components/Stack'; -export { ContainedTab as unstable_ContainedTab } from './components/Tab'; -export { ContainedTabs as unstable_ContainedTabs } from './components/Tabs'; export { Tooltip as unstable_Tooltip } from './components/Tooltip/next'; export unstable_TreeView, { TreeNode as unstable_TreeNode, } from './components/TreeView'; +export { + TabPanel as unstable_TabPanel, + TabPanels as unstable_TabPanels, + TabList as unstable_TabList, +} from './components/Tabs'; export { usePrefix as unstable_usePrefix } from './internal/usePrefix'; export { Theme as unstable_Theme, diff --git a/packages/react/src/internal/useNoInteractiveChildren.js b/packages/react/src/internal/useNoInteractiveChildren.js index f0e6eb4680d7..46005d99bcfd 100644 --- a/packages/react/src/internal/useNoInteractiveChildren.js +++ b/packages/react/src/internal/useNoInteractiveChildren.js @@ -32,7 +32,7 @@ export function useNoInteractiveChildren( * @param {HTMLElement} node * @returns {HTMLElement} */ -function getInteractiveContent(node) { +export function getInteractiveContent(node) { if (isFocusable(node)) { return node; } diff --git a/packages/styles/scss/components/tabs/_tabs.scss b/packages/styles/scss/components/tabs/_tabs.scss index 2435ec7c4716..74df93d2f43f 100644 --- a/packages/styles/scss/components/tabs/_tabs.scss +++ b/packages/styles/scss/components/tabs/_tabs.scss @@ -33,7 +33,6 @@ /// @access public /// @group tabs @mixin tabs { - // TODO: remove namespace and suffix in next major release .#{$prefix}--tabs { @include reset; @include type-style('body-short-01'); @@ -44,7 +43,7 @@ min-height: rem(40px); color: $text-primary; - &.#{$prefix}--tabs--container { + &.#{$prefix}--tabs--contained { min-height: rem(48px); } @@ -95,11 +94,11 @@ background-image: linear-gradient(to right, transparent, $layer); } - &.#{$prefix}--tabs--container .#{$prefix}--tabs__overflow-indicator--left { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--left { background-image: linear-gradient(to left, transparent, $layer-accent); } - &.#{$prefix}--tabs--container .#{$prefix}--tabs__overflow-indicator--right { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--right { background-image: linear-gradient(to right, transparent, $layer-accent); } @@ -124,7 +123,7 @@ ); } - &.#{$prefix}--tabs--container + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--left { background-image: linear-gradient( to left, @@ -133,7 +132,7 @@ ); } - &.#{$prefix}--tabs--container + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--right { background-image: linear-gradient( to right, @@ -162,7 +161,7 @@ display: none; } - &.#{$prefix}--tabs--container .#{$prefix}--tab--overflow-nav-button { + &.#{$prefix}--tabs--contained .#{$prefix}--tab--overflow-nav-button { width: $spacing-09; margin: 0; background-color: $layer-accent; @@ -189,11 +188,11 @@ margin-left: rem(1px); } - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item { background-color: $layer-accent; } - &.#{$prefix}--tabs--container + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item + .#{$prefix}--tabs__nav-item { margin-left: 0; @@ -201,87 +200,12 @@ box-shadow: rem(-1px) 0 0 0 $border-strong; } - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item - + .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--selected, - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--selected - + .#{$prefix}--tabs__nav-item { - box-shadow: none; - } - .#{$prefix}--tabs__nav-item .#{$prefix}--tabs__nav-link { transition: color $duration-fast-01 motion(standard, productive), border-bottom-color $duration-fast-01 motion(standard, productive), outline $duration-fast-01 motion(standard, productive); } - //----------------------------- - // Item Hover - //----------------------------- - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item:hover { - background-color: $layer-selected-hover; - } - - //--------------------------------------------- - // Item Disabled - //--------------------------------------------- - .#{$prefix}--tabs__nav-item--disabled, - .#{$prefix}--tabs__nav-item--disabled:hover { - background-color: transparent; - cursor: not-allowed; - outline: none; - } - - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled, - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled:hover { - background-color: button.$button-disabled; - } - - //----------------------------- - // Item Selected - //----------------------------- - .#{$prefix}--tabs__nav-item--selected { - transition: color $duration-fast-01 motion(standard, productive); - } - - .#{$prefix}--tabs__nav-item--selected .#{$prefix}--tabs__nav-link, - .#{$prefix}--tabs__nav-item--selected .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-item--selected .#{$prefix}--tabs__nav-link:active { - @include type-style('productive-heading-01'); - - border-bottom: 2px solid $border-interactive; - color: $text-primary; - } - - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item--selected, - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item--selected:hover { - background-color: $layer; - - .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-link:active { - box-shadow: none; - } - } - - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--selected - .#{$prefix}--tabs__nav-link { - // Draws the border without affecting the inner-content - box-shadow: inset 0 2px 0 0 $border-interactive; - // height - vertical padding - line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); - } - - &.#{$prefix}--tabs--light.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--selected, - &.#{$prefix}--tabs--light.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--selected:hover { - background-color: $background; - } - //----------------------------- // Link //----------------------------- @@ -295,10 +219,10 @@ } overflow: hidden; + max-width: 10rem; padding: $spacing-04 $spacing-05 $spacing-03; border-bottom: $tab-underline-color; color: $text-secondary; - text-align: left; text-decoration: none; text-overflow: ellipsis; transition: border $duration-fast-01 motion(standard, productive), @@ -311,12 +235,70 @@ } } - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-link { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-link { height: rem(48px); padding: $spacing-03 $spacing-05; border-bottom: 0; // height - vertical padding line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); + text-align: left; + } + + //----------------------------- + // Item Hover + //----------------------------- + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item:hover { + background-color: $layer-selected-hover; + } + + //----------------------------- + // Item Selected + //----------------------------- + .#{$prefix}--tabs__nav-item--selected { + border-bottom: 2px solid $border-interactive; + transition: color $duration-fast-01 motion(standard, productive); + } + + &.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item--selected + + .#{$prefix}--tabs__nav-item { + box-shadow: none; + } + + &.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--selected { + // Draws the border without affecting the inner-content + box-shadow: inset 0 2px 0 0 $border-interactive; + } + + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item--selected, + .#{$prefix}--tabs__nav-item--selected, + .#{$prefix}--tabs__nav-item--selected:focus + .#{$prefix}--tabs__nav-link:focus, + .#{$prefix}--tabs__nav-item--selected:active + .#{$prefix}--tabs__nav-link:active { + @include type-style('productive-heading-01'); + + color: $text-primary; + } + + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item--selected, + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item--selected:hover { + background-color: $layer; + // height - vertical padding + line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); + + .#{$prefix}--tabs__nav-link:focus, + .#{$prefix}--tabs__nav-link:active { + box-shadow: none; + } + } + + &.#{$prefix}--tabs--light.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item--selected, + &.#{$prefix}--tabs--light.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item--selected:hover { + background-color: $background; } //----------------------------- @@ -327,31 +309,35 @@ color: $text-primary; } - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item - .#{$prefix}--tabs__nav-link { - border-bottom: none; - } - //----------------------------- - // Link Disabled + // Item Disabled //----------------------------- - .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link { + + .#{$prefix}--tabs__nav-item--disabled { border-bottom: $tab-underline-disabled; + background-color: transparent; color: $tab-text-disabled; + outline: none; } - .#{$prefix}--tabs__nav-item--disabled:hover .#{$prefix}--tabs__nav-link { + .#{$prefix}--tabs__nav-item--disabled:hover { border-bottom: $tab-underline-disabled; color: $tab-text-disabled; cursor: not-allowed; - pointer-events: none; } - .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link:active { + &.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled, + &.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled:hover { + background-color: button.$button-disabled; + } + + .#{$prefix}--tabs__nav-item--disabled:focus, + .#{$prefix}--tabs__nav-item--disabled:active { border-bottom: $tab-underline-disabled; outline: none; + pointer-events: none; } .#{$prefix}--tabs--light @@ -375,9 +361,7 @@ border-bottom-color: $border-subtle; } - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--disabled - .#{$prefix}--tabs__nav-link { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item--disabled { border-bottom: none; color: $text-on-color-disabled; } @@ -401,7 +385,7 @@ pointer-events: none; } - .#{$prefix}--skeleton.#{$prefix}--tabs:not(.#{$prefix}--tabs--container) + .#{$prefix}--skeleton.#{$prefix}--tabs:not(.#{$prefix}--tabs--contained) .#{$prefix}--tabs__nav-link { border-bottom: 2px solid $skeleton-element; } @@ -429,7 +413,6 @@ @include high-contrast-mode('focus'); } - // stylelint-disable-next-line no-duplicate-selectors .#{$prefix}--tabs .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link {