diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 6f21e5c1427..f03df7fa256 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -24,6 +24,7 @@ const pluralizationMap = { ColumnLayout: 'ColumnLayouts', Container: 'Containers', ContentLayout: 'ContentLayouts', + ControlGroup: 'ControlGroups', CopyToClipboard: 'CopyToClipboards', DateInput: 'DateInputs', DatePicker: 'DatePickers', diff --git a/pages/control-group/action-button.page.tsx b/pages/control-group/action-button.page.tsx index 15f7e674992..5bdee7971e1 100644 --- a/pages/control-group/action-button.page.tsx +++ b/pages/control-group/action-button.page.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import ControlGroup, { ControlGroupProps } from '~components/internal/components/control-group'; +import ControlGroup, { ControlGroupProps } from '~components/control-group'; import { PermutationsPage } from '../app/templates'; import createPermutations from '../utils/permutations'; diff --git a/pages/control-group/common.tsx b/pages/control-group/common.tsx index 9954653417b..4ae74b4fb26 100644 --- a/pages/control-group/common.tsx +++ b/pages/control-group/common.tsx @@ -3,9 +3,9 @@ import React from 'react'; import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; +import { ControlGroupProps } from '~components/control-group/interfaces'; import FormField from '~components/form-field'; import Input from '~components/input'; -import { ControlGroupProps } from '~components/internal/components/control-group/interfaces'; import Multiselect, { MultiselectProps } from '~components/multiselect'; import RadioGroup from '~components/radio-group'; import SegmentedControl, { SegmentedControlProps } from '~components/segmented-control'; diff --git a/pages/control-group/in-app-layout.page.tsx b/pages/control-group/in-app-layout.page.tsx index 3d9cffb0bca..05506c176a3 100644 --- a/pages/control-group/in-app-layout.page.tsx +++ b/pages/control-group/in-app-layout.page.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Container, ContentLayout, Header, ItemCard, SpaceBetween } from '~components'; import AppLayout from '~components/app-layout'; -import ControlGroup from '~components/internal/components/control-group'; +import ControlGroup from '~components/control-group'; import labels from '../app-layout/utils/labels'; import ScreenshotArea from '../utils/screenshot-area'; diff --git a/pages/control-group/labels.page.tsx b/pages/control-group/labels.page.tsx index 84117d86f90..314a6bac2d9 100644 --- a/pages/control-group/labels.page.tsx +++ b/pages/control-group/labels.page.tsx @@ -4,8 +4,8 @@ import React from 'react'; import { SpaceBetween } from '~components'; import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; +import ControlGroup from '~components/control-group'; import Input from '~components/input'; -import ControlGroup from '~components/internal/components/control-group'; import Multiselect, { MultiselectProps } from '~components/multiselect'; import Select, { SelectProps } from '~components/select'; diff --git a/pages/control-group/permutations.page.tsx b/pages/control-group/permutations.page.tsx index 5f08a10b0fb..c869dac3f83 100644 --- a/pages/control-group/permutations.page.tsx +++ b/pages/control-group/permutations.page.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import ControlGroup, { ControlGroupProps } from '~components/internal/components/control-group'; +import ControlGroup, { ControlGroupProps } from '~components/control-group'; import { PermutationsPage } from '../app/templates'; import createPermutations from '../utils/permutations'; diff --git a/pages/control-group/responsive.page.tsx b/pages/control-group/responsive.page.tsx index 03bcbc444b1..b6e2fe51349 100644 --- a/pages/control-group/responsive.page.tsx +++ b/pages/control-group/responsive.page.tsx @@ -3,9 +3,9 @@ import React from 'react'; import Box from '~components/box'; +import ControlGroup from '~components/control-group'; import FormField from '~components/form-field'; import Input from '~components/input'; -import ControlGroup from '~components/internal/components/control-group'; import RadioGroup from '~components/radio-group'; import Select from '~components/select'; import SpaceBetween from '~components/space-between'; diff --git a/pages/control-group/validation.page.tsx b/pages/control-group/validation.page.tsx index 3f85f4a7c33..a61d23e29e9 100644 --- a/pages/control-group/validation.page.tsx +++ b/pages/control-group/validation.page.tsx @@ -4,9 +4,9 @@ import React from 'react'; import Autosuggest from '~components/autosuggest'; import Checkbox from '~components/checkbox'; +import ControlGroup, { ControlGroupProps } from '~components/control-group'; import FormField from '~components/form-field'; import Input from '~components/input'; -import ControlGroup, { ControlGroupProps } from '~components/internal/components/control-group'; import Multiselect from '~components/multiselect'; import RadioGroup from '~components/radio-group'; import SegmentedControl from '~components/segmented-control'; diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index f5c5c06ce3c..faa0a22efec 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -10844,6 +10844,292 @@ Note that the secondary header will not have a high-contrast treatement, even if } `; +exports[`Components definition for control-group matches the snapshot: control-group 1`] = ` +{ + "dashCaseName": "control-group", + "events": [], + "functions": [], + "name": "ControlGroup", + "properties": [ + { + "description": "Specifies an action button rendered next to the grouped controls.", + "inlineType": { + "name": "ControlGroupProps.ActionButton", + "properties": [ + { + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "name": "ariaLabel", + "optional": false, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "boolean", + }, + { + "name": "disabledReason", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "IconProps.Name", + "type": "union", + "values": [ + "search", + "map", + "filter", + "key", + "file", + "pause", + "play", + "microphone", + "remove", + "copy", + "menu", + "script", + "close", + "status-pending", + "refresh", + "external", + "history", + "group", + "calendar", + "ellipsis", + "zoom-in", + "zoom-out", + "dot", + "security", + "download", + "edit", + "add-plus", + "anchor-link", + "angle-left-double", + "angle-left", + "angle-right-double", + "angle-right", + "angle-up", + "angle-down", + "announcement", + "arrow-left", + "arrow-right", + "arrow-up", + "arrow-down", + "at-symbol", + "audio-full", + "audio-half", + "audio-off", + "backward-10-seconds", + "bug", + "call", + "caret-down-filled", + "caret-down", + "caret-left-filled", + "caret-right-filled", + "caret-up-filled", + "caret-up", + "check", + "contact", + "closed-caption", + "closed-caption-unavailable", + "command-prompt", + "convert-code", + "delete-marker", + "drag-indicator", + "edit-gen-ai", + "envelope", + "exit-full-screen", + "expand", + "face-happy", + "face-happy-filled", + "face-neutral", + "face-neutral-filled", + "face-sad", + "face-sad-filled", + "file-open", + "flag", + "folder-open", + "folder", + "forward-10-seconds", + "full-screen", + "gen-ai", + "gen-ai-token", + "globe", + "grid-view", + "group-active", + "heart", + "heart-filled", + "insert-row", + "internal-link", + "keyboard", + "light-dark", + "list-view", + "location-pin", + "lock-private", + "microphone-off", + "mini-player", + "multiscreen", + "notification", + "redo", + "resize-area", + "search-gen-ai", + "settings", + "send", + "share", + "shrink", + "sign-out", + "side-bar", + "slash", + "slash-divider", + "star-filled", + "star-half", + "star", + "status-in-progress", + "status-info", + "status-negative", + "status-not-started", + "status-positive", + "status-stopped", + "status-warning", + "stop-circle", + "subtract-minus", + "suggestions", + "suggestions-gen-ai", + "support", + "thumbs-down-filled", + "thumbs-down", + "thumbs-up-filled", + "thumbs-up", + "ticket", + "transcript", + "treeview-collapse", + "treeview-expand", + "undo", + "unlocked", + "upload-download", + "upload", + "user-profile-active", + "user-profile", + "video-off", + "video-on", + "video-unavailable", + "video-camera-off", + "video-camera-on", + "video-camera-unavailable", + "view-full", + "view-horizontal", + "view-vertical", + "zoom-to-fit", + ], + }, + "name": "iconName", + "optional": false, + "type": "string", + }, + { + "inlineType": { + "name": "CancelableEventHandler", + "parameters": [ + { + "name": "event", + "type": "CustomEvent", + }, + ], + "returnType": "void", + "type": "function", + }, + "name": "onClick", + "optional": true, + "type": "CancelableEventHandler", + }, + ], + "type": "object", + }, + "name": "actionButton", + "optional": true, + "type": "ControlGroupProps.ActionButton", + }, + { + "description": "Adds \`aria-describedby\` to the component. If you're using this component within a form field, +don't set this property because the form field component automatically sets it. + +Use this property if the component isn't surrounded by a form field, or you want to override the value +automatically set by the form field (for example, if you have two components within a single form field). + +To use it correctly, define an ID for each element that you want to use as a description +and set the property to a string of each ID separated by spaces (for example, \`"id1 id2 id3"\`).", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-labelledby\` to the component. If you're using this component within a form field, +don't set this property because the form field component automatically sets it. + +Use this property if the component isn't surrounded by a form field, or you want to override the value +automatically set by the form field (for example, if you have two components within a single form field). + +To use it correctly, define an ID for the element you want to use as label and set the property to that ID.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "defaultValue": "'auto'", + "description": "The axis along which the controls are laid out (\`horizontal\`, \`vertical\` or \`auto\`). +When set to \`auto\` (default), the component decides the direction based on whether it +fits in the padding box of its +[containing block](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Display/Containing_block).", + "inlineType": { + "name": "ControlGroupProps.ControlGroupDirection", + "type": "union", + "values": [ + "auto", + "horizontal", + "vertical", + ], + }, + "name": "direction", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "name": "inlineLabelText", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + exports[`Components definition for copy-to-clipboard matches the snapshot: copy-to-clipboard 1`] = ` { "dashCaseName": "copy-to-clipboard", @@ -15531,8 +15817,8 @@ for screen reader users.", "description": "The ID of the primary form control. You can use this to set the \`for\` attribute of a label for accessibility. -If you don't set this property, the control group automatically sets -the label to the ID of an inner form control (for example, an [input](/components/input) component). +If you don't set this property, the form field automatically generates an ID +and assigns it to the inner form control (for example, an [input](/components/input) component). This only works well if you're using a single control in the form field.", "name": "controlId", "optional": true, @@ -20252,7 +20538,9 @@ Set this property if the dropdown would otherwise be constrained by a scrollable for example inside table and split view layouts. We recommend you use discretion, and don't enable this property unless necessary -because fixed positioning results in a slight, visible lag when scrolling complex pages.", +because fixed positioning results in a slight, visible lag when scrolling complex pages. + +When the component is rendered in a control group, the dropdown will be rendered expanded to the viewport regardless of this property.", "name": "expandToViewport", "optional": true, "type": "boolean", @@ -20390,7 +20678,8 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "type": "string", }, { - "description": "Shows tokens inside the trigger instead of below it.", + "description": "Shows tokens inside the trigger instead of below it. +When the component is rendered inside a control group, the tokens are rendered inside the trigger regardless of this property.", "name": "inlineTokens", "optional": true, "type": "boolean", @@ -26698,7 +26987,9 @@ Set this property if the dropdown would otherwise be constrained by a scrollable for example inside table and split view layouts. We recommend you use discretion, and don't enable this property unless necessary -because fixed positioning results in a slight, visible lag when scrolling complex pages.", +because fixed positioning results in a slight, visible lag when scrolling complex pages. + +When the component is rendered in a control group, the dropdown will be rendered expanded to the viewport regardless of this property.", "name": "expandToViewport", "optional": true, "type": "boolean", @@ -40930,6 +41221,48 @@ Note that, despite its typings, this may return null for group items since group ], "name": "ContentLayoutWrapper", }, + { + "methods": [ + { + "description": "Returns the action button, or null if no action is set.", + "name": "findActionButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ButtonWrapper", + }, + }, + { + "description": "Returns an array of the individual grouped controls.", + "name": "findControls", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "Returns the visible inline label element, or null if no label is set.", + "name": "findInlineLabel", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + ], + "name": "ControlGroupWrapper", + }, { "methods": [ { @@ -52818,6 +53151,43 @@ Note that, despite its typings, this may return null for group items since group ], "name": "ContentLayoutWrapper", }, + { + "methods": [ + { + "description": "Returns the action button, or null if no action is set.", + "name": "findActionButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ButtonWrapper", + }, + }, + { + "description": "Returns an array of the individual grouped controls.", + "name": "findControls", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "Returns the visible inline label element, or null if no label is set.", + "name": "findInlineLabel", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + ], + "name": "ControlGroupWrapper", + }, { "methods": [ { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 984b0459c00..afd62836ff5 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -208,6 +208,12 @@ exports[`test-utils selectors 1`] = ` "awsui_notifications_64tge", "awsui_secondary-header_64tge", ], + "control-group": [ + "awsui_action-button_g8no8", + "awsui_control_g8no8", + "awsui_inline-label_g8no8", + "awsui_root_g8no8", + ], "copy-to-clipboard": [ "awsui_root_ljpwc", "awsui_text-to-copy_ljpwc", @@ -393,7 +399,6 @@ exports[`test-utils selectors 1`] = ` "awsui_root_2rhyz", ], "internal": [ - "awsui_action-button_1gp1v", "awsui_actions_x6dl3", "awsui_application_1fcus", "awsui_axis-label--x_f0fot", @@ -403,7 +408,6 @@ exports[`test-utils selectors 1`] = ` "awsui_button_m5h9f", "awsui_chart-filter_1px7g", "awsui_content_x6dl3", - "awsui_control_1gp1v", "awsui_control_1wepg", "awsui_custom-content_1p2cx", "awsui_description_1p2cx", @@ -422,7 +426,6 @@ exports[`test-utils selectors 1`] = ` "awsui_header_dgs8z", "awsui_highlighted_15o6u", "awsui_icon_x6dl3", - "awsui_inline-label_1gp1v", "awsui_inner-list-item_10ipo", "awsui_key_10ipo", "awsui_label-tag_1p2cx", @@ -442,7 +445,6 @@ exports[`test-utils selectors 1`] = ` "awsui_root_15oj2", "awsui_root_1b522", "awsui_root_1fcus", - "awsui_root_1gp1v", "awsui_root_1kjc7", "awsui_root_1om0h", "awsui_root_1qprf", diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 41b1607f330..0ae69c9b241 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -33,6 +33,7 @@ import CollectionPreferencesWrapper from './collection-preferences'; import ColumnLayoutWrapper from './column-layout'; import ContainerWrapper from './container'; import ContentLayoutWrapper from './content-layout'; +import ControlGroupWrapper from './control-group'; import CopyToClipboardWrapper from './copy-to-clipboard'; import DateInputWrapper from './date-input'; import DatePickerWrapper from './date-picker'; @@ -136,6 +137,7 @@ export { CollectionPreferencesWrapper }; export { ColumnLayoutWrapper }; export { ContainerWrapper }; export { ContentLayoutWrapper }; +export { ControlGroupWrapper }; export { CopyToClipboardWrapper }; export { DateInputWrapper }; export { DatePickerWrapper }; @@ -889,6 +891,34 @@ findAllContentLayouts(selector?: string): Array; * @returns {ContentLayoutWrapper | null} */ findClosestContentLayout(): ContentLayoutWrapper | null; +/** + * Returns the wrapper of the first ControlGroup that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first ControlGroup. + * If no matching ControlGroup is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {ControlGroupWrapper | null} + */ +findControlGroup(selector?: string): ControlGroupWrapper | null; + +/** + * Returns an array of ControlGroup wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the ControlGroups inside the current wrapper. + * If no matching ControlGroup is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllControlGroups(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent ControlGroup for the current element, + * or the element itself if it is an instance of ControlGroup. + * If no ControlGroup is found, returns \`null\`. + * + * @returns {ControlGroupWrapper | null} + */ +findClosestControlGroup(): ControlGroupWrapper | null; /** * Returns the wrapper of the first CopyToClipboard that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first CopyToClipboard. @@ -3361,6 +3391,19 @@ ElementWrapper.prototype.findContentLayout = function(selector) { ElementWrapper.prototype.findAllContentLayouts = function(selector) { return this.findAllComponents(ContentLayoutWrapper, selector); }; +ElementWrapper.prototype.findControlGroup = function(selector) { + let rootSelector = \`.\${ControlGroupWrapper.rootSelector}\`; + if("legacyRootSelector" in ControlGroupWrapper && ControlGroupWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${ControlGroupWrapper.rootSelector}, .\${ControlGroupWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, ControlGroupWrapper); +}; + +ElementWrapper.prototype.findAllControlGroups = function(selector) { + return this.findAllComponents(ControlGroupWrapper, selector); +}; ElementWrapper.prototype.findCopyToClipboard = function(selector) { let rootSelector = \`.\${CopyToClipboardWrapper.rootSelector}\`; if("legacyRootSelector" in CopyToClipboardWrapper && CopyToClipboardWrapper.legacyRootSelector){ @@ -4483,6 +4526,11 @@ ElementWrapper.prototype.findClosestContentLayout = function() { // https://github.com/microsoft/TypeScript/issues/29132 return (this as any).findClosestComponent(ContentLayoutWrapper); }; +ElementWrapper.prototype.findClosestControlGroup = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(ControlGroupWrapper); +}; ElementWrapper.prototype.findClosestCopyToClipboard = function() { // casting to 'any' is needed to avoid this issue with generics // https://github.com/microsoft/TypeScript/issues/29132 @@ -4911,6 +4959,7 @@ import CollectionPreferencesWrapper from './collection-preferences'; import ColumnLayoutWrapper from './column-layout'; import ContainerWrapper from './container'; import ContentLayoutWrapper from './content-layout'; +import ControlGroupWrapper from './control-group'; import CopyToClipboardWrapper from './copy-to-clipboard'; import DateInputWrapper from './date-input'; import DatePickerWrapper from './date-picker'; @@ -5014,6 +5063,7 @@ export { CollectionPreferencesWrapper }; export { ColumnLayoutWrapper }; export { ContainerWrapper }; export { ContentLayoutWrapper }; +export { ControlGroupWrapper }; export { CopyToClipboardWrapper }; export { DateInputWrapper }; export { DatePickerWrapper }; @@ -5503,6 +5553,23 @@ findContentLayout(selector?: string): ContentLayoutWrapper; * @returns {MultiElementWrapper} */ findAllContentLayouts(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the ControlGroups with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches ControlGroups. + * + * @param {string} [selector] CSS Selector + * @returns {ControlGroupWrapper} + */ +findControlGroup(selector?: string): ControlGroupWrapper; + +/** + * Returns a multi-element wrapper that matches ControlGroups with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches ControlGroups. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllControlGroups(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the CopyToClipboards with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches CopyToClipboards. @@ -7128,6 +7195,19 @@ ElementWrapper.prototype.findContentLayout = function(selector) { ElementWrapper.prototype.findAllContentLayouts = function(selector) { return this.findAllComponents(ContentLayoutWrapper, selector); }; +ElementWrapper.prototype.findControlGroup = function(selector) { + let rootSelector = \`.\${ControlGroupWrapper.rootSelector}\`; + if("legacyRootSelector" in ControlGroupWrapper && ControlGroupWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${ControlGroupWrapper.rootSelector}, .\${ControlGroupWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, ControlGroupWrapper); +}; + +ElementWrapper.prototype.findAllControlGroups = function(selector) { + return this.findAllComponents(ControlGroupWrapper, selector); +}; ElementWrapper.prototype.findCopyToClipboard = function(selector) { let rootSelector = \`.\${CopyToClipboardWrapper.rootSelector}\`; if("legacyRootSelector" in CopyToClipboardWrapper && CopyToClipboardWrapper.legacyRootSelector){ diff --git a/src/autosuggest/__tests__/control-group.test.tsx b/src/autosuggest/__tests__/control-group.test.tsx index 3a53fbe14be..7286a4d4d8c 100644 --- a/src/autosuggest/__tests__/control-group.test.tsx +++ b/src/autosuggest/__tests__/control-group.test.tsx @@ -4,9 +4,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import Autosuggest from '../../../lib/components/autosuggest'; -import ControlGroup from '../../../lib/components/internal/components/control-group'; +import ControlGroup from '../../../lib/components/control-group'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; const noop = () => {}; diff --git a/src/autosuggest/internal.tsx b/src/autosuggest/internal.tsx index d20acea2f37..c0c82e078b9 100644 --- a/src/autosuggest/internal.tsx +++ b/src/autosuggest/internal.tsx @@ -16,6 +16,7 @@ import { useDropdownStatus } from '../internal/components/dropdown-status'; import { checkOptionValueField } from '../internal/components/listbox/utils/check-option-value-field'; import { useGroupedControlContext } from '../internal/context/control-group-context'; import { fireCancelableEvent, fireNonCancelableEvent } from '../internal/events'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/grouped-control/dev-warnings'; import checkControlled from '../internal/hooks/check-controlled'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { BaseKeyDetail, NonCancelableCustomEvent } from '../types/events'; @@ -184,6 +185,16 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r const formFieldContext = useFormFieldContext(restProps); const groupedControlProps = useGroupedControlContext(); + + warnOnUnsupportedGroupedControlProps({ + isGrouped: !!groupedControlProps.position, + componentName: 'Autosuggest', + invalid: formFieldContext.invalid, + warning: formFieldContext.warning, + readOnly, + expandToViewport, + }); + // When grouped, the dropdown is portaled regardless of the public // `expandToViewport` prop so it escapes the control slot's stacking context // and paints over the group's inline label (which the focus ring stays under). diff --git a/src/internal/components/control-group/__integ__/control-group.test.ts b/src/control-group/__integ__/control-group.test.ts similarity index 96% rename from src/internal/components/control-group/__integ__/control-group.test.ts rename to src/control-group/__integ__/control-group.test.ts index 7d637f61df1..5e4ccce8047 100644 --- a/src/internal/components/control-group/__integ__/control-group.test.ts +++ b/src/control-group/__integ__/control-group.test.ts @@ -3,8 +3,8 @@ import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; -import createWrapper from '../../../../../lib/components/test-utils/selectors'; -import ControlGroupWrapper from '../../../../../lib/components/test-utils/selectors/internal/control-group'; +import createWrapper from '../../../lib/components/test-utils/selectors'; +import ControlGroupWrapper from '../../../lib/components/test-utils/selectors/control-group'; const wrapperSelector = '[data-testid="control-group-wrapper"]'; diff --git a/src/internal/components/control-group/__tests__/common.tsx b/src/control-group/__tests__/common.tsx similarity index 82% rename from src/internal/components/control-group/__tests__/common.tsx rename to src/control-group/__tests__/common.tsx index 2dad08dcc68..1c82e2d23eb 100644 --- a/src/internal/components/control-group/__tests__/common.tsx +++ b/src/control-group/__tests__/common.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { useGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context'; +import { useGroupedControlContext } from '../../../lib/components/internal/context/control-group-context'; export function PositionProbe({ testId = 'probe' }: { testId?: string }) { const { position } = useGroupedControlContext(); diff --git a/src/internal/components/control-group/__tests__/control-group.test.tsx b/src/control-group/__tests__/control-group.test.tsx similarity index 85% rename from src/internal/components/control-group/__tests__/control-group.test.tsx rename to src/control-group/__tests__/control-group.test.tsx index 7d488e53f26..0ad0384a7d5 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/control-group/__tests__/control-group.test.tsx @@ -3,19 +3,13 @@ import React from 'react'; import { render } from '@testing-library/react'; -import FormField from '../../../../../lib/components/form-field'; -import ControlGroup from '../../../../../lib/components/internal/components/control-group'; -import { ResetGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context'; -import Select from '../../../../../lib/components/select'; -import createWrapper from '../../../../../lib/components/test-utils/dom'; -import ControlGroupWrapper from '../../../../../lib/components/test-utils/dom/internal/control-group'; +import ControlGroup from '../../../lib/components/control-group'; +import FormField from '../../../lib/components/form-field'; +import { ResetGroupedControlContext } from '../../../lib/components/internal/context/control-group-context'; +import Select from '../../../lib/components/select'; +import createWrapper from '../../../lib/components/test-utils/dom'; import { DirectionProbe, PositionProbe } from './common'; -function findControlGroup(container: HTMLElement) { - const element = container.querySelector(`.${ControlGroupWrapper.rootSelector}`); - return element && new ControlGroupWrapper(element); -} - describe('Control group', () => { test('keeps focus on a control when the children are reordered', () => { const alpha = ; @@ -23,7 +17,7 @@ describe('Control group', () => { const { container, rerender } = render({[alpha, beta]}); - const alphaInput = findControlGroup(container)!.findControls()[0].find('input')!.getElement(); + const alphaInput = createWrapper(container).findControlGroup()!.findControls()[0].find('input')!.getElement(); alphaInput.focus(); expect(document.activeElement).toBe(alphaInput); @@ -31,7 +25,7 @@ describe('Control group', () => { rerender({[beta, alpha]}); // The same DOM node is still focused; it was moved, not remounted. - expect(findControlGroup(container)!.findControls()[1].find('input')!.getElement()).toBe(alphaInput); + expect(createWrapper(container).findControlGroup()!.findControls()[1].find('input')!.getElement()).toBe(alphaInput); expect(document.activeElement).toBe(alphaInput); }); @@ -43,7 +37,7 @@ describe('Control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('only'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('only'); }); test('exposes first / middle / last positions to each child in order', () => { @@ -55,7 +49,7 @@ describe('Control group', () => { ); - const controls = findControlGroup(container)!.findControls(); + const controls = createWrapper(container).findControlGroup()!.findControls(); expect(controls[0].getElement()).toHaveTextContent('first'); expect(controls[1].getElement()).toHaveTextContent('middle'); expect(controls[2].getElement()).toHaveTextContent('last'); @@ -72,7 +66,7 @@ describe('Control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('none'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('none'); }); }); @@ -84,7 +78,7 @@ describe('Control group', () => { ); - const actionButton = findControlGroup(container)!.findActionButton(); + const actionButton = createWrapper(container).findControlGroup()!.findActionButton(); expect(actionButton).not.toBeNull(); expect(actionButton!.getElement()).toHaveAccessibleName('Remove'); }); @@ -96,7 +90,7 @@ describe('Control group', () => { ); - expect(findControlGroup(container)!.findActionButton()).toBeNull(); + expect(createWrapper(container).findControlGroup()!.findActionButton()).toBeNull(); }); test('calls onClick when the action button is clicked', () => { @@ -107,7 +101,7 @@ describe('Control group', () => { ); - const actionButton = findControlGroup(container)!.findActionButton()!; + const actionButton = createWrapper(container).findControlGroup()!.findActionButton()!; actionButton.click(); expect(onClick).toHaveBeenCalledTimes(1); }); @@ -123,7 +117,7 @@ describe('Control group', () => { ); - const controls = findControlGroup(container)!.findControls(); + const controls = createWrapper(container).findControlGroup()!.findControls(); expect(controls[0].getElement()).toHaveTextContent('vertical'); expect(controls[1].getElement()).toHaveTextContent('vertical'); expect(controls[2].getElement()).toHaveTextContent('vertical'); @@ -138,7 +132,7 @@ describe('Control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('vertical'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('vertical'); }); }); @@ -151,7 +145,7 @@ describe('Control group', () => { ); const group = getByRole('group'); - const label = findControlGroup(container)!.findInlineLabel()!.getElement(); + const label = createWrapper(container).findControlGroup()!.findInlineLabel()!.getElement(); expect(label).toHaveTextContent('Threshold'); expect(label.id).toBeTruthy(); expect(group.getAttribute('aria-labelledby')).toBe(label.id); @@ -166,7 +160,7 @@ describe('Control group', () => { ); expect(getByRole('group').getAttribute('aria-labelledby')).toBeNull(); - expect(findControlGroup(container)!.findInlineLabel()).toBeNull(); + expect(createWrapper(container).findControlGroup()!.findInlineLabel()).toBeNull(); }); describe('FormField validation association', () => { @@ -231,7 +225,7 @@ describe('Control group', () => { inlineLabelText: 'Threshold', }); const group = getByRole('group'); - const inlineLabelId = findControlGroup(container)!.findInlineLabel()!.getElement().id; + const inlineLabelId = createWrapper(container).findControlGroup()!.findInlineLabel()!.getElement().id; const labelledby = group.getAttribute('aria-labelledby')!.split(' '); expect(labelledby).toContain(inlineLabelId); @@ -305,7 +299,7 @@ describe('Control group', () => { ); - const inlineLabelId = findControlGroup(container)!.findInlineLabel()!.getElement().id; + const inlineLabelId = createWrapper(container).findControlGroup()!.findInlineLabel()!.getElement().id; const labelledBy = getByRole('group').getAttribute('aria-labelledby')!; const tokens = labelledBy.split(' '); expect(tokens).toContain('external-label'); diff --git a/src/control-group/__tests__/dev-warnings.test.tsx b/src/control-group/__tests__/dev-warnings.test.tsx new file mode 100644 index 00000000000..1c358011266 --- /dev/null +++ b/src/control-group/__tests__/dev-warnings.test.tsx @@ -0,0 +1,169 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import Autosuggest from '../../../lib/components/autosuggest'; +import ControlGroup from '../../../lib/components/control-group'; +import Input from '../../../lib/components/input'; +import Multiselect from '../../../lib/components/multiselect'; +import Select from '../../../lib/components/select'; + +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + warnOnce: jest.fn(), +})); + +afterEach(() => { + (warnOnce as jest.Mock).mockReset(); +}); + +const options = [{ value: '1', label: 'One' }]; +const noop = () => {}; + +describe('Grouped control dev warnings', () => { + describe('invalid prop', () => { + test.each([ + ['Input', ], + ['Autosuggest', ], + ['Select', ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); + + describe('warning prop', () => { + test.each([ + ['Input', ], + [ + 'Autosuggest', + ''} warning={true} />, + ], + ['Select', ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); + + describe('readOnly prop', () => { + test.each([ + ['Input', ], + ['Autosuggest', ], + ['Select', ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); + + describe('multiselect inlineTokens prop', () => { + test('warns when inlineTokens is false inside a control group', () => { + render( + + + + ); + expect(warnOnce).toHaveBeenCalledWith( + 'Multiselect', + 'The `inlineTokens` prop is ignored inside a control group; tokens are always shown inline.' + ); + }); + + test('does not warn when inlineTokens is true inside a control group', () => { + render( + + + + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + + test('does not warn when inlineTokens is undefined inside a control group', () => { + render( + + + + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + + test('does not warn when inlineTokens is false for a standalone multiselect', () => { + render(); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); + + describe('expandToViewport prop', () => { + test.each([ + ['Autosuggest', ], + ['Select', + + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + + test('does not warn when expandToViewport is undefined inside a control group', () => { + render( + + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/control-group/index.tsx b/src/control-group/index.tsx new file mode 100644 index 00000000000..1bc304f138b --- /dev/null +++ b/src/control-group/index.tsx @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { ControlGroupProps } from './interfaces'; +import InternalControlGroup from './internal'; + +export { ControlGroupProps }; + +export default function ControlGroup({ direction = 'auto', ...props }: ControlGroupProps) { + const baseComponentProps = useBaseComponent('ControlGroup', { + props: { direction }, + metadata: { + hasInlineLabel: Boolean(props.inlineLabelText), + hasActionButton: Boolean(props.actionButton), + }, + }); + return ; +} + +applyDisplayName(ControlGroup, 'ControlGroup'); diff --git a/src/internal/components/control-group/interfaces.ts b/src/control-group/interfaces.ts similarity index 80% rename from src/internal/components/control-group/interfaces.ts rename to src/control-group/interfaces.ts index d9861c52c47..4a35334dcef 100644 --- a/src/internal/components/control-group/interfaces.ts +++ b/src/control-group/interfaces.ts @@ -1,11 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ButtonProps } from '../../../button/interfaces'; -import { IconProps } from '../../../icon/interfaces'; -import { BaseComponentProps } from '../../../types/base-component'; -import { FormFieldControlProps } from '../../../types/form-field'; -import { GroupedControlDirection } from '../../context/control-group-context'; -import { CancelableEventHandler } from '../../events'; +import { ButtonProps } from '../button/interfaces'; +import { IconProps } from '../icon/interfaces'; +import { BaseComponentProps } from '../types/base-component'; +import { CancelableEventHandler } from '../types/events'; +import { FormFieldControlProps } from '../types/form-field'; export interface ControlGroupProps extends BaseComponentProps, @@ -28,7 +27,7 @@ export interface ControlGroupProps export namespace ControlGroupProps { // `'auto'` exists only for the control group but not for the inner grouped controls, // which receive a definite value (`horizontal` or `vertical`). - export type ControlGroupDirection = GroupedControlDirection | 'auto'; + export type ControlGroupDirection = 'horizontal' | 'vertical' | 'auto'; export interface ActionButton { /** diff --git a/src/internal/components/control-group/index.tsx b/src/control-group/internal.tsx similarity index 88% rename from src/internal/components/control-group/index.tsx rename to src/control-group/internal.tsx index 86b9a15aa8c..6222345cb80 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/control-group/internal.tsx @@ -5,33 +5,33 @@ import clsx from 'clsx'; import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; -import { InternalButton } from '../../../button/internal'; -import { getBaseProps } from '../../base-component'; +import { InternalButton } from '../button/internal'; +import { getBaseProps } from '../internal/base-component'; import { GroupedControlContext, GroupedControlDirection, GroupedControlPosition, -} from '../../context/control-group-context'; -import { FormFieldContext, useFormFieldContext } from '../../context/form-field-context'; -import { useFitsInline } from '../../hooks/use-fits-inline'; -import { flattenChildren } from '../../utils/flatten-children'; -import { joinStrings } from '../../utils/strings/join-strings'; +} from '../internal/context/control-group-context'; +import { FormFieldContext, useFormFieldContext } from '../internal/context/form-field-context'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useFitsInline } from '../internal/hooks/use-fits-inline'; +import { flattenChildren } from '../internal/utils/flatten-children'; +import { joinStrings } from '../internal/utils/strings/join-strings'; import { ControlGroupProps } from './interfaces'; import styles from './styles.css.js'; import testUtilStyles from './test-classes/styles.css.js'; -export { ControlGroupProps }; - -export default function ControlGroup({ +export default function InternalControlGroup({ children, direction = 'auto', inlineLabelText, actionButton, ariaLabelledby, ariaDescribedby, + __internalRootRef, ...props -}: ControlGroupProps) { +}: ControlGroupProps & InternalBaseComponentProps) { const baseProps = getBaseProps(props); const labelId = useUniqueId('control-group-label'); @@ -143,6 +143,7 @@ export default function ControlGroup({ const groupLayoutWrapper = (
{renderGroupLayout(false)} @@ -162,7 +163,7 @@ export default function ControlGroup({ if (inlineLabelText) { return ( -
+
diff --git a/src/internal/components/control-group/styles.scss b/src/control-group/styles.scss similarity index 96% rename from src/internal/components/control-group/styles.scss rename to src/control-group/styles.scss index 1ecc6c36b3c..91909d2ff8f 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/control-group/styles.scss @@ -3,10 +3,10 @@ SPDX-License-Identifier: Apache-2.0 */ -@use '../../styles' as styles; -@use '../../styles/tokens' as awsui; -@use '../../styles/forms/mixins' as forms; -@use '../../styles/forms/constants' as constants; +@use '../internal/styles' as styles; +@use '../internal/styles/tokens' as awsui; +@use '../internal/styles/forms/mixins' as forms; +@use '../internal/styles/forms/constants' as constants; $margin-overlap: calc(-1 * #{awsui.$border-width-field}); diff --git a/src/internal/components/control-group/test-classes/styles.scss b/src/control-group/test-classes/styles.scss similarity index 100% rename from src/internal/components/control-group/test-classes/styles.scss rename to src/control-group/test-classes/styles.scss diff --git a/src/form-field/interfaces.ts b/src/form-field/interfaces.ts index 30e5f88b35d..7611a3eaa87 100644 --- a/src/form-field/interfaces.ts +++ b/src/form-field/interfaces.ts @@ -10,8 +10,8 @@ export interface FormFieldProps extends BaseComponentProps { * The ID of the primary form control. You can use this to set the * `for` attribute of a label for accessibility. * - * If you don't set this property, the control group automatically sets - * the label to the ID of an inner form control (for example, an [input](/components/input) component). + * If you don't set this property, the form field automatically generates an ID + * and assigns it to the inner form control (for example, an [input](/components/input) component). * This only works well if you're using a single control in the form field. */ controlId?: string; diff --git a/src/input/__tests__/control-group.test.tsx b/src/input/__tests__/control-group.test.tsx index 649ca45a9a6..d0e7d6e7fea 100644 --- a/src/input/__tests__/control-group.test.tsx +++ b/src/input/__tests__/control-group.test.tsx @@ -3,18 +3,13 @@ import React from 'react'; import { render } from '@testing-library/react'; +import ControlGroup from '../../../lib/components/control-group'; import Input from '../../../lib/components/input'; -import ControlGroup from '../../../lib/components/internal/components/control-group'; -import ControlGroupWrapper from '../../../lib/components/test-utils/dom/internal/control-group'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { PositionProbe } from '../../control-group/__tests__/common'; const noop = () => {}; -function findControlGroup(container: HTMLElement) { - const element = container.querySelector(`.${ControlGroupWrapper.rootSelector}`); - return element && new ControlGroupWrapper(element); -} - describe('Input in control group', () => { test('resets the context for prefix content', () => { const { container } = render( @@ -23,7 +18,7 @@ describe('Input in control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('none'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('none'); }); test('resets the context for suffix content', () => { @@ -33,6 +28,6 @@ describe('Input in control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('none'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('none'); }); }); diff --git a/src/input/internal.tsx b/src/input/internal.tsx index c4a040a37bb..d8662a9b66e 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -14,10 +14,11 @@ import { useInternalI18n } from '../i18n/context'; import { IconProps } from '../icon/interfaces'; import InternalIcon from '../icon/internal'; import { getBaseProps } from '../internal/base-component'; -import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/grouped-control/dev-warnings'; +import { getGroupedControlClassNames } from '../internal/grouped-control/grouped-control-styles'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useDebounceCallback } from '../internal/hooks/use-debounce-callback'; import { isDevelopment } from '../internal/is-development'; @@ -162,6 +163,14 @@ function InternalInput( const generatedControlId = useUniqueId('input'); const controlId = controlIdFromFormFieldContext ?? (inlineLabelText ? generatedControlId : undefined); + warnOnUnsupportedGroupedControlProps({ + isGrouped: !!groupedControlProps.position, + componentName: 'Input', + invalid: invalid, + warning: warning, + readOnly, + }); + const hasPrefix = !!prefix; const hasSuffix = !!suffix; const hasAdornments = hasPrefix || hasSuffix; diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index 801563b56a0..c2797102547 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -29,6 +29,7 @@ import { getBreakpointValue } from '../../breakpoints'; import { ResetGroupedControlContext, useGroupedControlContext } from '../../context/control-group-context'; import { useFormFieldContext } from '../../context/form-field-context'; import { fireCancelableEvent, fireNonCancelableEvent } from '../../events'; +import { getGroupedControlClassNames } from '../../grouped-control/grouped-control-styles'; import { InternalBaseComponentProps } from '../../hooks/use-base-component'; import { useIMEComposition } from '../../hooks/use-ime-composition'; import { useListFocusController } from '../../hooks/use-list-focus-controller'; @@ -38,7 +39,6 @@ import { getInputStylesCss } from '../../utils/input-styles'; import { nodeBelongs } from '../../utils/node-belongs'; import { processAttributes } from '../../utils/with-native-attributes'; import ButtonTrigger from '../button-trigger'; -import { getGroupedControlClassNames } from '../control-group/grouped-control-styles'; import { InlineTokens, InlineTokensRef } from './inline-tokens'; import { useTokenOverflow } from './use-token-overflow'; diff --git a/src/internal/components/button-trigger/index.tsx b/src/internal/components/button-trigger/index.tsx index 4498e0c21b7..c4b9c6d3b81 100644 --- a/src/internal/components/button-trigger/index.tsx +++ b/src/internal/components/button-trigger/index.tsx @@ -12,7 +12,7 @@ import { BaseKeyDetail, CancelableEventHandler } from '../../../types/events'; import { getBaseProps } from '../../base-component'; import { GroupedControlContextProps } from '../../context/control-group-context'; import { fireCancelableEvent, fireKeyboardEvent } from '../../events'; -import { getGroupedControlClassNames } from '../control-group/grouped-control-styles'; +import { getGroupedControlClassNames } from '../../grouped-control/grouped-control-styles'; import { GeneratedAnalyticsMetadataButtonTriggerCollapse, GeneratedAnalyticsMetadataButtonTriggerExpand, diff --git a/src/internal/grouped-control/dev-warnings.ts b/src/internal/grouped-control/dev-warnings.ts new file mode 100644 index 00000000000..612ee712fa9 --- /dev/null +++ b/src/internal/grouped-control/dev-warnings.ts @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { isDevelopment } from '../is-development'; + +export interface GroupedControlWarningsProps { + /** + * Whether the control is currently rendered inside a control group. + */ + isGrouped: boolean; + /** + * The public component name used as the prefix in the dev warning + * (for example `'Input'` or `'Multiselect'`). + */ + componentName: string; + /** + * The resolved value of the control's `invalid` prop. + */ + invalid?: boolean; + /** + * The resolved value of the control's `warning` prop. + */ + warning?: boolean; + /** + * The resolved value of the control's `readOnly` prop. + */ + readOnly?: boolean; + /** + * The resolved value of the multiselect's `inlineTokens` prop. Only + * meaningful for multiselect. + */ + inlineTokens?: boolean; + /** + * The resolved value of the control's `expandToViewport` prop. Only + * meaningful for the dropdown controls (autosuggest, select, multiselect);. + */ + expandToViewport?: boolean; +} + +/** + * Emits development-only warnings for props that are unsupported when one of the + * grouped-aware controls is rendered inside a control group. + */ +export function warnOnUnsupportedGroupedControlProps({ + isGrouped, + componentName, + invalid, + warning, + readOnly, + inlineTokens, + expandToViewport, +}: GroupedControlWarningsProps): void { + if (!isDevelopment || !isGrouped) { + return; + } + + if (invalid) { + warnOnce( + componentName, + 'The `invalid` prop is ignored on controls inside a control group. Set the validation state on the control group instead by wrapping it in a form field.' + ); + } + + if (warning) { + warnOnce( + componentName, + 'The `warning` prop is ignored on controls inside a control group. Set the validation state on the control group instead by wrapping it in a form field.' + ); + } + + if (readOnly) { + warnOnce(componentName, 'The `readOnly` prop is not supported on controls inside a control group.'); + } + + if (inlineTokens === false) { + warnOnce( + componentName, + 'The `inlineTokens` prop is ignored inside a control group; tokens are always shown inline.' + ); + } + + if (expandToViewport === false) { + warnOnce( + componentName, + 'The `expandToViewport` prop is ignored inside a control group; the dropdown is always expanded to the viewport.' + ); + } +} diff --git a/src/internal/components/control-group/grouped-control-styles.ts b/src/internal/grouped-control/grouped-control-styles.ts similarity index 91% rename from src/internal/components/control-group/grouped-control-styles.ts rename to src/internal/grouped-control/grouped-control-styles.ts index 892dbad8a7c..d683fe09549 100644 --- a/src/internal/components/control-group/grouped-control-styles.ts +++ b/src/internal/grouped-control/grouped-control-styles.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import clsx from 'clsx'; -import { GroupedControlContextProps } from '../../context/control-group-context'; +import { GroupedControlContextProps } from '../context/control-group-context'; /** * Builds the shared grouped-control CSS classes for a consumer. diff --git a/src/multiselect/__tests__/control-group.test.tsx b/src/multiselect/__tests__/control-group.test.tsx index c8bec7c0d9e..a02796fa019 100644 --- a/src/multiselect/__tests__/control-group.test.tsx +++ b/src/multiselect/__tests__/control-group.test.tsx @@ -3,10 +3,10 @@ import React from 'react'; import { render } from '@testing-library/react'; -import ControlGroup from '../../../lib/components/internal/components/control-group'; +import ControlGroup from '../../../lib/components/control-group'; import Multiselect from '../../../lib/components/multiselect'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; const options = [{ value: '1', label: 'One' }]; const noop = () => {}; diff --git a/src/multiselect/interfaces.ts b/src/multiselect/interfaces.ts index 1ca60ab205e..7cd9dbecbb7 100644 --- a/src/multiselect/interfaces.ts +++ b/src/multiselect/interfaces.ts @@ -33,6 +33,7 @@ export interface MultiselectProps extends BaseSelectProps { hideTokens?: boolean; /** * Shows tokens inside the trigger instead of below it. + * When the component is rendered inside a control group, the tokens are rendered inside the trigger regardless of this property. */ inlineTokens?: boolean; /** diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx index d72ad0a38be..4f31667b5aa 100644 --- a/src/multiselect/internal.tsx +++ b/src/multiselect/internal.tsx @@ -13,6 +13,7 @@ import DropdownFooter from '../internal/components/dropdown-footer/index.js'; import ScreenreaderOnly from '../internal/components/screenreader-only'; import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; import { useFormFieldContext } from '../internal/context/form-field-context'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/grouped-control/dev-warnings'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component/index.js'; import { SomeRequired } from '../internal/types'; import { getDropdownMinWidth } from '../internal/utils/get-dropdown-min-width'; @@ -56,7 +57,7 @@ const InternalMultiselect = React.forwardRef( tokenLimit, i18nStrings, virtualScroll, - inlineTokens = false, + inlineTokens, hideTokens, expandToViewport, tokenLimitShowFewerAriaLabel, @@ -80,6 +81,16 @@ const InternalMultiselect = React.forwardRef( const isGrouped = !!groupedControlProps.position; + warnOnUnsupportedGroupedControlProps({ + isGrouped, + componentName: 'Multiselect', + invalid: formFieldContext.invalid, + warning: formFieldContext.warning, + readOnly, + inlineTokens, + expandToViewport, + }); + // When rendered inside a control group, tokens are always shown inline in the // trigger regardless of the `inlineTokens` prop, since there is no room to // display tokens below the control within a group. diff --git a/src/segmented-control/internal-segmented-control.tsx b/src/segmented-control/internal-segmented-control.tsx index 57e212deaeb..c070a6843a2 100644 --- a/src/segmented-control/internal-segmented-control.tsx +++ b/src/segmented-control/internal-segmented-control.tsx @@ -3,9 +3,9 @@ import React, { useRef } from 'react'; import clsx from 'clsx'; -import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; import { GroupedControlContextProps } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; +import { getGroupedControlClassNames } from '../internal/grouped-control/grouped-control-styles'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { KeyCode } from '../internal/keycode'; import handleKey from '../internal/utils/handle-key'; diff --git a/src/segmented-control/internal.tsx b/src/segmented-control/internal.tsx index aeb42491d7a..3078647e003 100644 --- a/src/segmented-control/internal.tsx +++ b/src/segmented-control/internal.tsx @@ -5,9 +5,9 @@ import clsx from 'clsx'; import InternalFormField from '../form-field/internal'; import { getBaseProps } from '../internal/base-component'; -import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; import { useGroupedControlContext } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; +import { getGroupedControlClassNames } from '../internal/grouped-control/grouped-control-styles'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import InternalSelect, { InternalSelectProps } from '../select/internal'; import { SegmentedControlProps } from './interfaces'; diff --git a/src/select/__tests__/control-group.test.tsx b/src/select/__tests__/control-group.test.tsx index df208ec11ef..c18ad2a2920 100644 --- a/src/select/__tests__/control-group.test.tsx +++ b/src/select/__tests__/control-group.test.tsx @@ -3,10 +3,10 @@ import React from 'react'; import { render } from '@testing-library/react'; -import ControlGroup from '../../../lib/components/internal/components/control-group'; +import ControlGroup from '../../../lib/components/control-group'; import Select from '../../../lib/components/select'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; describe('Select control in control group', () => { test('resets the context for custom dropdown content', () => { diff --git a/src/select/interfaces.ts b/src/select/interfaces.ts index 9179d95962d..1ebf78f5348 100644 --- a/src/select/interfaces.ts +++ b/src/select/interfaces.ts @@ -178,6 +178,21 @@ export interface BaseSelectProps * Adds `aria-describedby` to the dropdown's listbox/dialog content. */ dropdownAriaDescribedby?: string; + + /** + * By default, the dropdown height is constrained to fit inside the height of its next scrollable container element. + * Enabling this property will allow the dropdown to extend beyond that container by using fixed positioning and + * [React Portals](https://reactjs.org/docs/portals.html). + * + * Set this property if the dropdown would otherwise be constrained by a scrollable container, + * for example inside table and split view layouts. + * + * We recommend you use discretion, and don't enable this property unless necessary + * because fixed positioning results in a slight, visible lag when scrolling complex pages. + * + * When the component is rendered in a control group, the dropdown will be rendered expanded to the viewport regardless of this property. + */ + expandToViewport?: boolean; } export interface SelectProps extends BaseSelectProps { diff --git a/src/select/internal.tsx b/src/select/internal.tsx index 003053a7ece..3146b6335c8 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -17,6 +17,7 @@ import { prepareOptions } from '../internal/components/option/utils/prepare-opti import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireNonCancelableEvent } from '../internal/events'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/grouped-control/dev-warnings'; import checkControlled from '../internal/hooks/check-controlled'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { SomeRequired } from '../internal/types'; @@ -85,6 +86,15 @@ const InternalSelect = React.forwardRef( const formFieldContext = useFormFieldContext(restProps); const groupedControlProps = useGroupedControlContext(); + warnOnUnsupportedGroupedControlProps({ + isGrouped: !!groupedControlProps.position, + componentName: 'Select', + invalid: formFieldContext.invalid, + warning: formFieldContext.warning, + readOnly, + expandToViewport, + }); + // When grouped, the dropdown is portaled regardless of the public // `expandToViewport` prop so it escapes the control slot's stacking context // and paints over the group's inline label (which the focus ring stays under). diff --git a/src/test-utils/dom/internal/control-group.ts b/src/test-utils/dom/control-group/index.ts similarity index 89% rename from src/test-utils/dom/internal/control-group.ts rename to src/test-utils/dom/control-group/index.ts index 96f9bff1c76..9213fc29a8e 100644 --- a/src/test-utils/dom/internal/control-group.ts +++ b/src/test-utils/dom/control-group/index.ts @@ -4,7 +4,7 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils- import ButtonWrapper from '../button'; -import testUtilStyles from '../../../internal/components/control-group/test-classes/styles.selectors.js'; +import testUtilStyles from '../../../control-group/test-classes/styles.selectors.js'; export default class ControlGroupWrapper extends ComponentWrapper { static rootSelector: string = testUtilStyles.root;