diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4efe9794a85..f9709559ed73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,7 @@ jobs: with: node-version: '20.x' - uses: actions/cache@v3 + if: github.event_name != 'merge_group' id: cache with: path: | @@ -88,6 +89,7 @@ jobs: with: node-version: '20.x' - uses: actions/cache@v3 + if: github.event_name != 'merge_group' id: cache with: path: | @@ -127,6 +129,7 @@ jobs: with: node-version: '20.x' - uses: actions/cache@v3 + if: github.event_name != 'merge_group' id: cache with: path: | @@ -186,6 +189,7 @@ jobs: with: node-version: '20.x' - uses: actions/cache@v3 + if: github.event_name != 'merge_group' id: cache with: path: | diff --git a/e2e/components/Menu/Menu-test.avt.e2e.js b/e2e/components/Menu/Menu-test.avt.e2e.js new file mode 100644 index 000000000000..ecf249ecb777 --- /dev/null +++ b/e2e/components/Menu/Menu-test.avt.e2e.js @@ -0,0 +1,69 @@ +/** + * Copyright IBM Corp. 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const { expect, test } = require('@playwright/test'); +const { visitStory } = require('../../test-utils/storybook'); + +test.describe('Menu @avt', () => { + test('@avt-default-state', async ({ page }) => { + await visitStory(page, { + component: 'Menu', + id: 'components-menu--playground', + globals: { + theme: 'white', + }, + }); + await expect(page).toHaveNoACViolations('Menu @avt-default-state'); + }); + + test('@avt-keyboard-nav Menu', async ({ page }) => { + await visitStory(page, { + component: 'Menu', + id: 'components-menu--playground', + globals: { + theme: 'white', + }, + }); + + const firstItem = page.getByRole('menuitem', { name: 'Share with' }); + const LastItem = page.getByRole('menuitem', { name: 'Delete' }); + const nestedMenu = page.getByRole('menu', { name: 'Share with' }); + const nestedMenuItem = page + .getByRole('menuitemradio', { + name: 'None', + }) + .first(); + + await expect(firstItem).toBeVisible(); + await expect(LastItem).toBeVisible(); + await expect(nestedMenu).not.toBeVisible(); + await expect(firstItem).toBeFocused(); + + // Should go to last item when focused on the first item and arrow up is pressed + await page.keyboard.press('ArrowUp'); + await expect(LastItem).toBeFocused(); + + // Should open menu with ArrowRight and focus on first item + await page.keyboard.press('ArrowDown'); + await expect(firstItem).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(nestedMenu).toBeVisible(); + await expect(nestedMenuItem).toBeVisible(); + await expect(nestedMenuItem).toBeFocused(); + await expect(nestedMenuItem).not.toBeChecked(); + + // Should select item with enter key + await page.keyboard.press('Enter'); + await expect(nestedMenuItem).toBeChecked(); + + // Should close menu with ArrowLeft + await page.keyboard.press('ArrowLeft'); + await expect(nestedMenu).not.toBeVisible(); + }); +}); diff --git a/e2e/components/Menu/Menu-test.e2e.js b/e2e/components/Menu/Menu-test.e2e.js index cf3309d3dd24..7541bc740e50 100644 --- a/e2e/components/Menu/Menu-test.e2e.js +++ b/e2e/components/Menu/Menu-test.e2e.js @@ -7,9 +7,9 @@ 'use strict'; -const { expect, test } = require('@playwright/test'); +const { test } = require('@playwright/test'); const { themes } = require('../../test-utils/env'); -const { snapshotStory, visitStory } = require('../../test-utils/storybook'); +const { snapshotStory } = require('../../test-utils/storybook'); test.describe('Menu', () => { themes.forEach((theme) => { @@ -23,15 +23,4 @@ test.describe('Menu', () => { }); }); }); - - test('accessibility-checker @avt', async ({ page }) => { - await visitStory(page, { - component: 'Menu', - id: 'components-menu--playground', - globals: { - theme: 'white', - }, - }); - await expect(page).toHaveNoACViolations('Menu'); - }); }); diff --git a/e2e/components/Toggletip/Toggletip-test.avt.e2e.js b/e2e/components/Toggletip/Toggletip-test.avt.e2e.js new file mode 100644 index 000000000000..26525fc2519b --- /dev/null +++ b/e2e/components/Toggletip/Toggletip-test.avt.e2e.js @@ -0,0 +1,57 @@ +/** + * Copyright IBM Corp. 2016, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +import { expect, test } from '@playwright/test'; +import { visitStory } from '../../test-utils/storybook'; + +test.describe('Toggletip @avt', () => { + test('@avt-default-state Toggletip', async ({ page }) => { + await visitStory(page, { + component: 'Toggletip', + id: 'components-toggletip--default', + globals: { + theme: 'white', + }, + }); + await expect(page).toHaveNoACViolations('Toggletip'); + }); + + test('@avt-keyboard-nav Toggletip', async ({ page }) => { + await visitStory(page, { + component: 'Toggletip', + id: 'components-toggletip--default', + globals: { + theme: 'white', + }, + }); + + // Checking if the defaultOpen is working + await expect(page.locator('.cds--popover--open')).toBeVisible(); + + // Checking first Toggletip interaction + await page.keyboard.press('Tab'); + await expect(page.getByLabel('Show information').first()).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.locator('.cds--popover--open')).toBeVisible(); + // Tabbing inside the popover + await page.keyboard.press('Tab'); + await expect(page.locator('.cds--link').first()).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Button' })).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.locator('.cds--popover--open')).not.toBeVisible(); + + // Checking second Toggletip interaction and close on Escape key + await expect(page.getByLabel('Show information').last()).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.locator('.cds--popover--open')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('.cds--popover--open')).not.toBeVisible(); + }); +}); diff --git a/e2e/components/Toggletip/Toggletip-test.e2e.js b/e2e/components/Toggletip/Toggletip-test.e2e.js index df57ebbc70fb..b64f2a196073 100644 --- a/e2e/components/Toggletip/Toggletip-test.e2e.js +++ b/e2e/components/Toggletip/Toggletip-test.e2e.js @@ -7,9 +7,9 @@ 'use strict'; -const { expect, test } = require('@playwright/test'); -const { themes } = require('../../test-utils/env'); -const { snapshotStory, visitStory } = require('../../test-utils/storybook'); +import { test } from '@playwright/test'; +import { themes } from '../../test-utils/env'; +import { snapshotStory } from '../../test-utils/storybook'; test.describe('Toggletip', () => { themes.forEach((theme) => { @@ -23,15 +23,4 @@ test.describe('Toggletip', () => { }); }); }); - - test('accessibility-checker @avt', async ({ page }) => { - await visitStory(page, { - component: 'Toggletip', - id: 'components-toggletip--default', - globals: { - theme: 'white', - }, - }); - await expect(page).toHaveNoACViolations('Toggletip'); - }); }); diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 0e53df0816fd..4fb469ca7068 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -1054,6 +1054,9 @@ Map { "ComboBox" => Object { "$$typeof": Symbol(react.forward_ref), "propTypes": Object { + "allowCustomValue": Object { + "type": "bool", + }, "aria-label": Object { "type": "string", }, @@ -1945,6 +1948,7 @@ Map { }, }, "TableExpandRow": Object { + "$$typeof": Symbol(react.forward_ref), "propTypes": Object { "aria-controls": Object { "type": "string", @@ -1979,6 +1983,7 @@ Map { "type": "func", }, }, + "render": [Function], }, "TableExpandedRow": Object { "propTypes": Object { @@ -7557,6 +7562,7 @@ Map { }, }, "TableExpandRow" => Object { + "$$typeof": Symbol(react.forward_ref), "propTypes": Object { "aria-controls": Object { "type": "string", @@ -7591,6 +7597,7 @@ Map { "type": "func", }, }, + "render": [Function], }, "TableExpandedRow" => Object { "propTypes": Object { diff --git a/packages/react/src/components/ComboBox/ComboBox-test.js b/packages/react/src/components/ComboBox/ComboBox-test.js index 36e0d8ee8374..dca836a0b8c8 100644 --- a/packages/react/src/components/ComboBox/ComboBox-test.js +++ b/packages/react/src/components/ComboBox/ComboBox-test.js @@ -101,6 +101,21 @@ describe('ComboBox', () => { }); }); + it('should retain value if custom value is entered and `allowCustomValue` is set', async () => { + render(); + + expect(findInputNode()).toHaveDisplayValue(''); + + await userEvent.type(findInputNode(), 'Apple'); + // Should close menu and keep value in input, even though it is not in the item list + await userEvent.keyboard('[Enter]'); + assertMenuClosed(); + expect(findInputNode()).toHaveDisplayValue('Apple'); + // Should retain value on blur + await userEvent.keyboard('[Tab]'); + expect(findInputNode()).toHaveDisplayValue('Apple'); + }); + describe('should display initially selected item found in `initialSelectedItem`', () => { it('using an object type for the `initialSelectedItem` prop', () => { render( diff --git a/packages/react/src/components/ComboBox/ComboBox.mdx b/packages/react/src/components/ComboBox/ComboBox.mdx index 956c9dcacdcf..06fae50fa306 100644 --- a/packages/react/src/components/ComboBox/ComboBox.mdx +++ b/packages/react/src/components/ComboBox/ComboBox.mdx @@ -21,6 +21,7 @@ import ComboBox from '../ComboBox'; - [itemToElement](#itemtoelement) - [itemToString](#itemtostring) - [shouldFilterItem](#shouldfilteritem) +- [allowCustomValue](#allowcustomvalue) - [With Layer](#with-layer) - [Feedback](#feedback) @@ -147,6 +148,19 @@ const filterItems = (menu) => { />; ``` +## `allowCustomValue` + +By default, if text is entered into the `Combobox` and it does not match an +item, it will be cleared on blur. However, you can change this behavior by +passing in the `allowCustomValue` prop. This will allow a user to close the menu +and accept a custom value by pressing `Enter` as well as retain the value on +blur. The `inputValue` is provided as a second argument to the `onChange` +callback. + +```js +{selectedItem: undefined, inputValue: 'Apple'} +``` + ## With Layer diff --git a/packages/react/src/components/ComboBox/ComboBox.stories.js b/packages/react/src/components/ComboBox/ComboBox.stories.js index dc6197a1d912..b987255bf215 100644 --- a/packages/react/src/components/ComboBox/ComboBox.stories.js +++ b/packages/react/src/components/ComboBox/ComboBox.stories.js @@ -79,6 +79,32 @@ export const Default = () => ( ); +export const AllowCustomValue = () => { + const filterItems = (menu) => { + return menu?.item?.toLowerCase().includes(menu?.inputValue?.toLowerCase()); + }; + return ( +
+ { + console.log(e); + }} + id="carbon-combobox" + items={['Apple', 'Orange', 'Banana', 'Pineapple', 'Raspberry', 'Lime']} + downshiftProps={{ + onStateChange: () => { + console.log('the state has changed'); + }, + }} + titleText="ComboBox title" + helperText="Combobox helper text" + /> +
+ ); +}; + export const _WithLayer = () => ( {(layer) => ( diff --git a/packages/react/src/components/ComboBox/ComboBox.tsx b/packages/react/src/components/ComboBox/ComboBox.tsx index 61914df39043..00060d69c922 100644 --- a/packages/react/src/components/ComboBox/ComboBox.tsx +++ b/packages/react/src/components/ComboBox/ComboBox.tsx @@ -55,6 +55,7 @@ const { clickButton, blurButton, changeInput, + blurInput, } = Downshift.stateChangeTypes; const defaultItemToString = (item: ItemType | null) => { @@ -127,13 +128,20 @@ const getInstanceId = setupGetInstanceId(); type ExcludedAttributes = 'id' | 'onChange' | 'onClick' | 'type' | 'size'; interface OnChangeData { - selectedItem: ItemType | null; + selectedItem: ItemType | null | undefined; + inputValue?: string | null; } type ItemToStringHandler = (item: ItemType | null) => string; export interface ComboBoxProps extends Omit, ExcludedAttributes> { + /** + * Specify whether or not the ComboBox should allow a value that is + * not in the list to be entered in the input + */ + allowCustomValue?: boolean; + /** * Specify a label to be read by screen readers on the container node * 'aria-label' of the ListBox component. @@ -329,6 +337,7 @@ const ComboBox = forwardRef( translateWithId, warn, warnText, + allowCustomValue = false, ...rest } = props; const prefix = usePrefix(); @@ -447,6 +456,14 @@ const ComboBox = forwardRef( case changeInput: updateHighlightedIndex(getHighlightedIndex(changes)); break; + case blurInput: + if (allowCustomValue) { + setInputValue(inputValue); + if (onChange) { + onChange({ selectedItem, inputValue }); + } + } + break; } }; @@ -571,8 +588,18 @@ const ComboBox = forwardRef( event.stopPropagation(); } - if (match(event, keys.Enter) && !inputValue) { + if ( + match(event, keys.Enter) && + (!inputValue || allowCustomValue) + ) { toggleMenu(); + + // Since `onChange` does not normally fire when the menu is closed, we should + // manually fire it when `allowCustomValue` is provided, the menu is closing, + // and there is a value. + if (allowCustomValue && isOpen && inputValue) { + onChange({ selectedItem, inputValue }); + } } if (match(event, keys.Escape) && inputValue) { @@ -744,6 +771,12 @@ const ComboBox = forwardRef( ComboBox.displayName = 'ComboBox'; ComboBox.propTypes = { + /** + * Specify whether or not the ComboBox should allow a value that is + * not in the list to be entered in the input + */ + allowCustomValue: PropTypes.bool, + /** * 'aria-label' of the ListBox component. * Specify a label to be read by screen readers on the container node diff --git a/packages/react/src/components/ContentSwitcher/ContentSwitcher.tsx b/packages/react/src/components/ContentSwitcher/ContentSwitcher.tsx index 3f3129543476..384b978973d2 100644 --- a/packages/react/src/components/ContentSwitcher/ContentSwitcher.tsx +++ b/packages/react/src/components/ContentSwitcher/ContentSwitcher.tsx @@ -56,7 +56,7 @@ export interface ContentSwitcherProps /** * Choose whether or not to automatically change selection on focus */ - selectionMode: 'automatic' | 'manual'; + selectionMode?: 'automatic' | 'manual'; /** * Specify the size of the Content Switcher. Currently supports either `sm`, 'md' (default) or 'lg` as an option. diff --git a/packages/react/src/components/DataTable/TableExpandRow.tsx b/packages/react/src/components/DataTable/TableExpandRow.tsx index 9c2e604a76a6..24356f76cc2f 100644 --- a/packages/react/src/components/DataTable/TableExpandRow.tsx +++ b/packages/react/src/components/DataTable/TableExpandRow.tsx @@ -54,66 +54,73 @@ interface TableExpandRowProps extends PropsWithChildren { onExpand: MouseEventHandler; } -const TableExpandRow = ({ - ['aria-controls']: ariaControls, - ['aria-label']: ariaLabel, - ariaLabel: deprecatedAriaLabel, - className: rowClassName, - children, - isExpanded, - onExpand, - expandIconDescription, - isSelected, - expandHeader = 'expand', - ...rest -}: TableExpandRowProps) => { - const prefix = usePrefix(); - const className = cx( +const TableExpandRow = React.forwardRef( + ( { - [`${prefix}--parent-row`]: true, - [`${prefix}--expandable-row`]: isExpanded, - [`${prefix}--data-table--selected`]: isSelected, - }, - rowClassName - ); - const previousValue = isExpanded ? 'collapsed' : undefined; - - return ( - - - - - {children} - - ); -}; + ['aria-controls']: ariaControls, + ['aria-label']: ariaLabel, + ariaLabel: deprecatedAriaLabel, + className: rowClassName, + children, + isExpanded, + onExpand, + expandIconDescription, + isSelected, + expandHeader = 'expand', + ...rest + }: TableExpandRowProps, + ref: React.Ref + ) => { + const prefix = usePrefix(); + const className = cx( + { + [`${prefix}--parent-row`]: true, + [`${prefix}--expandable-row`]: isExpanded, + [`${prefix}--data-table--selected`]: isSelected, + }, + rowClassName + ); + const previousValue = isExpanded ? 'collapsed' : undefined; + + return ( + + + + + {children} + + ); + } +); TableExpandRow.propTypes = { /** * Space separated list of one or more ID values referencing the TableExpandedRow(s) being controlled by the TableExpandRow * TODO: make this required in v12 */ + /**@ts-ignore*/ ['aria-controls']: PropTypes.string, /** * Specify the string read by a voice reader when the expand trigger is * focused */ + /**@ts-ignore*/ ['aria-label']: PropTypes.string, /** @@ -152,4 +159,5 @@ TableExpandRow.propTypes = { onExpand: PropTypes.func.isRequired, }; +TableExpandRow.displayName = 'TableExpandRow'; export default TableExpandRow; diff --git a/packages/react/src/components/Dropdown/Dropdown.tsx b/packages/react/src/components/Dropdown/Dropdown.tsx index 4284fc96c448..25206b4a6b24 100644 --- a/packages/react/src/components/Dropdown/Dropdown.tsx +++ b/packages/react/src/components/Dropdown/Dropdown.tsx @@ -184,7 +184,9 @@ export interface DropdownProps * An optional callback to render the currently selected item as a react element instead of only * as a string. */ - renderSelectedItem?(item: ItemType): string; + renderSelectedItem?( + item: ItemType + ): React.JSXElementConstructor | null; /** * In the case you want to control the dropdown selection entirely. diff --git a/packages/styles/scss/components/slider/_slider.scss b/packages/styles/scss/components/slider/_slider.scss index 67ff6f1f6c60..212a6c8fca27 100644 --- a/packages/styles/scss/components/slider/_slider.scss +++ b/packages/styles/scss/components/slider/_slider.scss @@ -146,7 +146,7 @@ block-size: convert.to-rem(2px); content: ''; inline-size: convert.to-rem(6px); - inset-block-start: calc(50% - #{convert.to-rem(2px) / 2}); + inset-block-start: calc(50% - #{convert.to-rem(2px) * 0.5}); inset-inline-end: 0; }