diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 76078281f6..c3f11c3ed9 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -59,6 +59,7 @@ const pluralizationMap = { ProgressBar: 'ProgressBars', PromptInput: 'PromptInputs', PropertyFilter: 'PropertyFilters', + RadioButton: 'RadioButtons', RadioGroup: 'RadioGroups', S3ResourceSelector: 'S3ResourceSelectors', SegmentedControl: 'SegmentedControls', diff --git a/pages/radio-button/common.tsx b/pages/radio-button/common.tsx new file mode 100644 index 0000000000..44c99302c8 --- /dev/null +++ b/pages/radio-button/common.tsx @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import RadioButton, { RadioButtonProps } from '~components/radio-button'; + +import createPermutations from '../utils/permutations'; + +const shortText = 'Short text'; + +export const longText = + 'Long text, long enough to wrap. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Whatever.'; + +type Permutation = Omit; + +export const permutations = createPermutations([ + { + description: [undefined, shortText, longText], + children: [undefined, shortText, longText], + readOnly: [false, true], + disabled: [false, true], + checked: [true, false], + }, +]); + +export const RadioButtonPermutation = ({ + children, + description, + readOnly, + disabled, + checked, + index, +}: Permutation & { index?: number }) => { + const commonProps = { + children, + description, + readOnly, + disabled, + checked, + name: `radio-group-${index}`, + }; + if (children) { + return ; + } else { + // If no visual label provided, add a label for screen readers + return ( + + ); + } +}; diff --git a/pages/radio-button/custom-style.tsx b/pages/radio-button/custom-style.tsx new file mode 100644 index 0000000000..d50e07afa4 --- /dev/null +++ b/pages/radio-button/custom-style.tsx @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { palette } from '../app/themes/style-api'; + +export default { + input: { + stroke: { + default: palette.neutral80, + disabled: palette.neutral100, + readOnly: palette.neutral90, + }, + fill: { + checked: palette.teal80, + default: palette.neutral10, + disabled: palette.neutral60, + readOnly: palette.neutral40, + }, + circle: { + fill: { + checked: palette.neutral10, + disabled: palette.neutral10, + readOnly: palette.neutral80, + }, + }, + focusRing: { + borderColor: palette.teal80, + borderRadius: '2px', + borderWidth: '1px', + }, + }, + label: { + color: { + checked: `light-dark(${palette.neutral100}, ${palette.neutral10})`, + default: `light-dark(${palette.neutral100}, ${palette.neutral10})`, + disabled: `light-dark(${palette.neutral80}, ${palette.neutral40})`, + readOnly: `light-dark(${palette.neutral80}, ${palette.neutral40})`, + }, + }, + description: { + color: { + checked: `light-dark(${palette.neutral100}, ${palette.neutral10})`, + default: `light-dark(${palette.neutral100}, ${palette.neutral10})`, + disabled: `light-dark(${palette.neutral80}, ${palette.neutral40})`, + readOnly: `light-dark(${palette.neutral80}, ${palette.neutral40})`, + }, + }, +}; diff --git a/pages/radio-button/focus-test.page.tsx b/pages/radio-button/focus-test.page.tsx new file mode 100644 index 0000000000..2c1de42d18 --- /dev/null +++ b/pages/radio-button/focus-test.page.tsx @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import RadioButton from '~components/radio-button'; + +import FocusTarget from '../common/focus-target'; +import ScreenshotArea from '../utils/screenshot-area'; + +export default function RadioButtonScenario() { + const [checked, setChecked] = useState(false); + return ( +
+

Radio buttons should be focused using the correct highlight

+ + + setChecked(true)}> + Radio button label + + +
+ ); +} diff --git a/pages/radio-button/permutations.page.tsx b/pages/radio-button/permutations.page.tsx new file mode 100644 index 0000000000..b952259482 --- /dev/null +++ b/pages/radio-button/permutations.page.tsx @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { SimplePage } from '../app/templates'; +import PermutationsView from '../utils/permutations-view'; +import { permutations, RadioButtonPermutation } from './common'; + +export default function RadioButtonPermutations() { + return ( + + } + /> + + ); +} diff --git a/pages/radio-button/style-custom.page.tsx b/pages/radio-button/style-custom.page.tsx new file mode 100644 index 0000000000..dd028d7752 --- /dev/null +++ b/pages/radio-button/style-custom.page.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { SimplePage } from '../app/templates'; +import PermutationsView from '../utils/permutations-view'; +import { permutations, RadioButtonPermutation } from './common'; +import customStyle from './custom-style'; + +export default function RadioButtonPermutations() { + return ( + + } + /> + + ); +} diff --git a/pages/radio-button/styles.scss b/pages/radio-button/styles.scss new file mode 100644 index 0000000000..a9ea24efb3 --- /dev/null +++ b/pages/radio-button/styles.scss @@ -0,0 +1,19 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '~design-tokens' as awsui; + +.radio-group { + display: flex; + column-gap: awsui.$space-scaled-m; + row-gap: awsui.$space-scaled-xs; + &--horizontal { + flex-direction: row; + flex-wrap: wrap; + } + &--vertical { + flex-direction: column; + } +} diff --git a/pages/radio-group/common-permutations.tsx b/pages/radio-group/common-permutations.tsx index 3e9cc0994f..a5583b74aa 100644 --- a/pages/radio-group/common-permutations.tsx +++ b/pages/radio-group/common-permutations.tsx @@ -5,6 +5,7 @@ import React from 'react'; import Link from '~components/link'; import { RadioGroupProps } from '~components/radio-group'; +import { longText } from '../radio-button/common'; import createPermutations from '../utils/permutations'; const permutations = createPermutations([ @@ -19,15 +20,12 @@ const permutations = createPermutations([ [ { value: 'first', - label: - 'Long text, long enough to wrap. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Whatever.', + label: longText, }, { value: 'second', - label: - 'Long text, long enough to wrap. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Whatever.', - description: - 'Long text, long enough to wrap. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Whatever.', + label: longText, + description: longText, }, ], ], diff --git a/pages/radio-group/style-custom.page.tsx b/pages/radio-group/style-custom.page.tsx index 29ff870930..15ca77483b 100644 --- a/pages/radio-group/style-custom.page.tsx +++ b/pages/radio-group/style-custom.page.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { RadioGroup, SpaceBetween } from '~components'; -import { palette } from '../app/themes/style-api'; +import customStyle from '../radio-button/custom-style'; import ScreenshotArea from '../utils/screenshot-area'; export default function CustomRadio() { @@ -27,59 +27,15 @@ export default function CustomRadio() { }, ]; - const style = { - input: { - stroke: { - default: palette.neutral80, - disabled: palette.neutral100, - readOnly: palette.neutral90, - }, - fill: { - checked: palette.teal80, - default: palette.neutral10, - disabled: palette.neutral60, - readOnly: palette.neutral40, - }, - circle: { - fill: { - checked: palette.neutral10, - disabled: palette.neutral10, - readOnly: palette.neutral80, - }, - }, - focusRing: { - borderColor: palette.teal80, - borderRadius: '2px', - borderWidth: '1px', - }, - }, - label: { - color: { - checked: `light-dark(${palette.neutral100}, ${palette.neutral10})`, - default: `light-dark(${palette.neutral100}, ${palette.neutral10})`, - disabled: `light-dark(${palette.neutral80}, ${palette.neutral40})`, - readOnly: `light-dark(${palette.neutral80}, ${palette.neutral40})`, - }, - }, - description: { - color: { - checked: `light-dark(${palette.neutral100}, ${palette.neutral10})`, - default: `light-dark(${palette.neutral100}, ${palette.neutral10})`, - disabled: `light-dark(${palette.neutral80}, ${palette.neutral40})`, - readOnly: `light-dark(${palette.neutral80}, ${palette.neutral40})`, - }, - }, - }; - return (

Custom Radio

- - - + + +
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index ba20b1e7ae..0faf0e3242 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -20222,6 +20222,361 @@ This is only shown when \`statusType\` is set to \`finished\` or not set at all. } `; +exports[`Components definition for radio-button matches the snapshot: radio-button 1`] = ` +{ + "dashCaseName": "radio-button", + "events": [ + { + "cancelable": false, + "description": "Called when the user clicks on the radio button and it is not disabled or read-only.", + "name": "onSelect", + }, + ], + "functions": [ + { + "description": "Sets input focus onto the UI control.", + "name": "focus", + "parameters": [], + "returnType": "void", + }, + ], + "name": "RadioButton", + "properties": [ + { + "description": "Specifies if the component is selected.", + "name": "checked", + "optional": false, + "type": "boolean", + }, + { + "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", + }, + { + "description": "Specifies the ID of the native form element. You can use it to relate +a label element's \`for\` attribute to this control.", + "name": "controlId", + "optional": true, + "type": "string", + }, + { + "description": "Specifies if the control is disabled, which prevents the +user from modifying the value and prevents the value from +being included in a form submission. A disabled control can't +receive focus.", + "name": "disabled", + "optional": true, + "type": "boolean", + }, + { + "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", + }, + { + "description": "Name of the group that the radio button belongs to.", + "name": "name", + "optional": false, + "type": "string", + }, + { + "description": "Attributes to add to the native \`input\` element. +Some attributes will be automatically combined with internal attribute values: +- \`className\` will be appended. +- Event handlers will be chained, unless the default is prevented. + +We do not support using this attribute to apply custom styling.", + "inlineType": { + "name": "Omit, "children"> & Record<\`data-\${string}\`, string>", + "type": "union", + "values": [ + "Omit, "children">", + "Record<\`data-\${string}\`, string>", + ], + }, + "name": "nativeInputAttributes", + "optional": true, + "systemTags": [ + "core", + ], + "type": "Omit, "children"> & Record<\`data-\${string}\`, string>", + }, + { + "description": "Specifies if the radio button is read-only, which prevents the +user from modifying the value, but does not prevent the value from +being included in a form submission. A read-only control is still focusable. + +This property should be set for either all or none of the radio buttons in a group.", + "name": "readOnly", + "optional": true, + "type": "boolean", + }, + { + "inlineType": { + "name": "RadioButtonProps.Style", + "properties": [ + { + "inlineType": { + "name": "object", + "properties": [ + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "checked", + "optional": true, + "type": "string", + }, + { + "name": "default", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "string", + }, + { + "name": "readOnly", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "color", + "optional": true, + "type": "{ checked?: string | undefined; default?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; }", + }, + ], + "type": "object", + }, + "name": "description", + "optional": true, + "type": "{ color?: { checked?: string | undefined; default?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; } | undefined; }", + }, + { + "inlineType": { + "name": "object", + "properties": [ + { + "inlineType": { + "name": "object", + "properties": [ + { + "inlineType": { + "name": "{ checked?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; }", + "properties": [ + { + "name": "checked", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "string", + }, + { + "name": "readOnly", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "fill", + "optional": true, + "type": "{ checked?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; }", + }, + ], + "type": "object", + }, + "name": "circle", + "optional": true, + "type": "{ fill?: { checked?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; } | undefined; }", + }, + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "checked", + "optional": true, + "type": "string", + }, + { + "name": "default", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "string", + }, + { + "name": "readOnly", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "fill", + "optional": true, + "type": "{ checked?: string | undefined; default?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; }", + }, + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "borderColor", + "optional": true, + "type": "string", + }, + { + "name": "borderRadius", + "optional": true, + "type": "string", + }, + { + "name": "borderWidth", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "focusRing", + "optional": true, + "type": "{ borderColor?: string | undefined; borderRadius?: string | undefined; borderWidth?: string | undefined; }", + }, + { + "inlineType": { + "name": "{ default?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; }", + "properties": [ + { + "name": "default", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "string", + }, + { + "name": "readOnly", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "stroke", + "optional": true, + "type": "{ default?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; }", + }, + ], + "type": "object", + }, + "name": "input", + "optional": true, + "type": "{ fill?: { checked?: string | undefined; default?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; } | undefined; stroke?: { default?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; } | undefined; circle?: { ...; } | undefined; focusRing?: { ...; }...", + }, + { + "inlineType": { + "name": "object", + "properties": [ + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "checked", + "optional": true, + "type": "string", + }, + { + "name": "default", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "string", + }, + { + "name": "readOnly", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "color", + "optional": true, + "type": "{ checked?: string | undefined; default?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; }", + }, + ], + "type": "object", + }, + "name": "label", + "optional": true, + "type": "{ color?: { checked?: string | undefined; default?: string | undefined; disabled?: string | undefined; readOnly?: string | undefined; } | undefined; }", + }, + ], + "type": "object", + }, + "name": "style", + "optional": true, + "systemTags": [ + "core", + ], + "type": "RadioButtonProps.Style", + }, + { + "description": "Sets the value attribute to the native control. +If using native form submission, this value is sent to the server if the radio button is checked. +It is never shown to the user by their user agent. +For more details, see the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/radio#value).", + "name": "value", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The control's label that's displayed next to the radio button. A state change occurs when a user clicks on it.", + "displayName": "label", + "isDefault": true, + "name": "children", + }, + { + "description": "Description that appears below the label.", + "isDefault": false, + "name": "description", + }, + ], + "releaseStatus": "stable", + "systemTags": [ + "core", + ], +} +`; + exports[`Components definition for radio-group matches the snapshot: radio-group 1`] = ` { "dashCaseName": "radio-group", 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 cb85907a8b..9e433a47f5 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 @@ -66,6 +66,7 @@ import PopoverWrapper from './popover'; import ProgressBarWrapper from './progress-bar'; import PromptInputWrapper from './prompt-input'; import PropertyFilterWrapper from './property-filter'; +import RadioButtonWrapper from './radio-button'; import RadioGroupWrapper from './radio-group'; import S3ResourceSelectorWrapper from './s3-resource-selector'; import SegmentedControlWrapper from './segmented-control'; @@ -152,6 +153,7 @@ export { PopoverWrapper }; export { ProgressBarWrapper }; export { PromptInputWrapper }; export { PropertyFilterWrapper }; +export { RadioButtonWrapper }; export { RadioGroupWrapper }; export { S3ResourceSelectorWrapper }; export { SegmentedControlWrapper }; @@ -1266,6 +1268,25 @@ findPropertyFilter(selector?: string): PropertyFilterWrapper | null; * @returns {Array} */ findAllPropertyFilters(selector?: string): Array; +/** + * Returns the wrapper of the first RadioButton that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first RadioButton. + * If no matching RadioButton is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {RadioButtonWrapper | null} + */ +findRadioButton(selector?: string): RadioButtonWrapper | null; + +/** + * Returns an array of RadioButton wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the RadioButtons inside the current wrapper. + * If no matching RadioButton is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllRadioButtons(selector?: string): Array; /** * Returns the wrapper of the first RadioGroup that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first RadioGroup. @@ -2524,6 +2545,19 @@ ElementWrapper.prototype.findPropertyFilter = function(selector) { ElementWrapper.prototype.findAllPropertyFilters = function(selector) { return this.findAllComponents(PropertyFilterWrapper, selector); }; +ElementWrapper.prototype.findRadioButton = function(selector) { + let rootSelector = \`.\${RadioButtonWrapper.rootSelector}\`; + if("legacyRootSelector" in RadioButtonWrapper && RadioButtonWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${RadioButtonWrapper.rootSelector}, .\${RadioButtonWrapper.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, RadioButtonWrapper); +}; + +ElementWrapper.prototype.findAllRadioButtons = function(selector) { + return this.findAllComponents(RadioButtonWrapper, selector); +}; ElementWrapper.prototype.findRadioGroup = function(selector) { let rootSelector = \`.\${RadioGroupWrapper.rootSelector}\`; if("legacyRootSelector" in RadioGroupWrapper && RadioGroupWrapper.legacyRootSelector){ @@ -2952,6 +2986,7 @@ import PopoverWrapper from './popover'; import ProgressBarWrapper from './progress-bar'; import PromptInputWrapper from './prompt-input'; import PropertyFilterWrapper from './property-filter'; +import RadioButtonWrapper from './radio-button'; import RadioGroupWrapper from './radio-group'; import S3ResourceSelectorWrapper from './s3-resource-selector'; import SegmentedControlWrapper from './segmented-control'; @@ -3038,6 +3073,7 @@ export { PopoverWrapper }; export { ProgressBarWrapper }; export { PromptInputWrapper }; export { PropertyFilterWrapper }; +export { RadioButtonWrapper }; export { RadioGroupWrapper }; export { S3ResourceSelectorWrapper }; export { SegmentedControlWrapper }; @@ -4038,6 +4074,23 @@ findPropertyFilter(selector?: string): PropertyFilterWrapper; * @returns {MultiElementWrapper} */ findAllPropertyFilters(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the RadioButtons with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches RadioButtons. + * + * @param {string} [selector] CSS Selector + * @returns {RadioButtonWrapper} + */ +findRadioButton(selector?: string): RadioButtonWrapper; + +/** + * Returns a multi-element wrapper that matches RadioButtons with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches RadioButtons. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllRadioButtons(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the RadioGroups with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches RadioGroups. @@ -5242,6 +5295,19 @@ ElementWrapper.prototype.findPropertyFilter = function(selector) { ElementWrapper.prototype.findAllPropertyFilters = function(selector) { return this.findAllComponents(PropertyFilterWrapper, selector); }; +ElementWrapper.prototype.findRadioButton = function(selector) { + let rootSelector = \`.\${RadioButtonWrapper.rootSelector}\`; + if("legacyRootSelector" in RadioButtonWrapper && RadioButtonWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${RadioButtonWrapper.rootSelector}, .\${RadioButtonWrapper.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, RadioButtonWrapper); +}; + +ElementWrapper.prototype.findAllRadioButtons = function(selector) { + return this.findAllComponents(RadioButtonWrapper, selector); +}; ElementWrapper.prototype.findRadioGroup = function(selector) { let rootSelector = \`.\${RadioGroupWrapper.rootSelector}\`; if("legacyRootSelector" in RadioGroupWrapper && RadioGroupWrapper.legacyRootSelector){ diff --git a/src/internal/components/radio-button/index.tsx b/src/internal/components/radio-button/index.tsx index 31658cbd26..9258f62934 100644 --- a/src/internal/components/radio-button/index.tsx +++ b/src/internal/components/radio-button/index.tsx @@ -12,6 +12,7 @@ import { getBaseProps } from '../../base-component'; import AbstractSwitch from '../../components/abstract-switch'; import { fireNonCancelableEvent } from '../../events'; import { InternalBaseComponentProps } from '../../hooks/use-base-component'; +import WithNativeAttributes from '../../utils/with-native-attributes'; import { RadioButtonProps } from './interfaces'; import styles from './styles.css.js'; @@ -26,10 +27,11 @@ export default React.forwardRef(function RadioButton( description, disabled, controlId, - onChange, readOnly, className, style, + nativeInputAttributes, + onSelect, ...rest }: RadioButtonProps & InternalBaseComponentProps, ref: React.Ref @@ -55,8 +57,11 @@ export default React.forwardRef(function RadioButton( __internalRootRef={rest.__internalRootRef} {...copyAnalyticsMetadataAttribute(rest)} nativeControl={nativeControlProps => ( - > {...nativeControlProps} + tag="input" + componentName="RadioButton" + nativeAttributes={nativeInputAttributes} tabIndex={tabIndex} type="radio" ref={mergedRefs} @@ -70,7 +75,9 @@ export default React.forwardRef(function RadioButton( )} onClick={() => { radioButtonRef.current?.focus(); - fireNonCancelableEvent(onChange, { checked: !checked }); + if (!checked) { + fireNonCancelableEvent(onSelect); + } }} styledControl={
+ + +
+ ).container + ); + expect(wrapper.findAllRadioButtons()).toHaveLength(2); + }); + + test('findLabel', () => { + const radioButton = renderRadioButton( + + My radio button label + + ); + expect(radioButton.findLabel().getElement().textContent).toBe('My radio button label'); + }); + + test('findDescription', () => { + const radioButton = renderRadioButton( + + ); + expect(radioButton.findDescription()!.getElement().textContent).toBe('My radio button description'); + }); + + test('findNativeInput', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement().tagName).toBe('INPUT'); + }); +}); diff --git a/src/radio-button/__tests__/radio-button.test.tsx b/src/radio-button/__tests__/radio-button.test.tsx new file mode 100644 index 0000000000..46f99ac023 --- /dev/null +++ b/src/radio-button/__tests__/radio-button.test.tsx @@ -0,0 +1,119 @@ +// 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 { RadioButtonProps } from '../../../lib/components/internal/components/radio-button/interfaces'; +import RadioButton from '../../../lib/components/radio-button'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { renderRadioButton } from './common'; + +describe('Radio Button native attributes from props', () => { + test('sets the `checked` attribute of the native element to true when `checked` is true', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement().checked).toBe(true); + }); + + test('sets the `checked` attribute of the native element to false when `checked` is false', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement().checked).toBe(false); + }); + + test('propagates the value of the `name` prop to the `name` attribute of the native element', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement().getAttribute('name')).toBe('my-radio-group-name'); + }); + + test('propagates the value of the `value` prop to the `value` attribute of the native element', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement().getAttribute('value')).toBe('my-radio-button-value'); + }); +}); + +describe('Radio Button events', () => { + test('fires a single onSelect event on input click', () => { + const onSelect = jest.fn(); + const radioButton = renderRadioButton(); + radioButton.findNativeInput()!.click(); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + test('fires a single onSelect event on label click', () => { + const onSelect = jest.fn(); + const radioButton = renderRadioButton( + + My radio button label + + ); + radioButton.findLabel()!.click(); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + test('does not fire onSelect when already checked', () => { + const onSelect = jest.fn(); + const radioButton = renderRadioButton(); + + radioButton.findNativeInput().click(); + + expect(radioButton.findNativeInput().getElement()).toBeChecked(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test('fires a single onSelect event on description click', () => { + const onSelect = jest.fn(); + const radioButton = renderRadioButton( + + ); + radioButton.findDescription()!.click(); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + test('does not trigger onSelect if disabled', () => { + const onSelect = jest.fn(); + const radioButton = renderRadioButton( + + ); + + radioButton.findLabel().click(); + + expect(radioButton.findNativeInput().getElement()).not.toBeChecked(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test('does not trigger onSelect if readOnly', () => { + const onSelect = jest.fn(); + const radioButton = renderRadioButton( + + ); + + radioButton.findLabel().click(); + + expect(radioButton.findNativeInput().getElement()).not.toBeChecked(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test('can be focused via API', () => { + let checkboxRef: RadioButtonProps.Ref | null = null; + + const radioButton = renderRadioButton( + (checkboxRef = ref)} checked={false} /> + ); + expect(checkboxRef).toBeDefined(); + + checkboxRef!.focus(); + expect(radioButton.findNativeInput().getElement()).toHaveFocus(); + }); + + test('does not trigger any change events when value is changed through API', () => { + const onSelect = jest.fn(); + const { container, rerender } = render(); + const radioButton = createWrapper(container).findRadioButton()!; + expect(radioButton.findNativeInput().getElement()).not.toBeChecked(); + + rerender(); + expect(radioButton.findNativeInput().getElement()).toBeChecked(); + + rerender(); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/radio-button/index.tsx b/src/radio-button/index.tsx new file mode 100644 index 0000000000..437fe4b5a0 --- /dev/null +++ b/src/radio-button/index.tsx @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; +import clsx from 'clsx'; + +import InternalRadioButton from '../internal/components/radio-button'; +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { RadioButtonProps } from './interfaces'; + +import styles from './styles.css.js'; + +export { RadioButtonProps }; + +const RadioButton = React.forwardRef((props: RadioButtonProps, ref: React.Ref) => { + const baseComponentProps = useBaseComponent('RadioButton', { + props: { readOnly: Boolean(props.readOnly), disabled: Boolean(props.disabled) }, + }); + return ( + + ); +}); + +applyDisplayName(RadioButton, 'RadioButton'); + +/** + * @awsuiSystem core + */ +export default RadioButton; diff --git a/src/radio-button/interfaces.ts b/src/radio-button/interfaces.ts new file mode 100644 index 0000000000..cc019d590b --- /dev/null +++ b/src/radio-button/interfaces.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { RadioButtonProps } from '../internal/components/radio-button/interfaces'; + +export { RadioButtonProps }; diff --git a/src/radio-button/styles.scss b/src/radio-button/styles.scss new file mode 100644 index 0000000000..46cfea8d53 --- /dev/null +++ b/src/radio-button/styles.scss @@ -0,0 +1,10 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles' as styles; + +.radio-button { + @include styles.styles-reset; +} diff --git a/src/radio-group/__tests__/radio-group.test.tsx b/src/radio-group/__tests__/radio-group.test.tsx index 5867dda105..5e524b9eaa 100644 --- a/src/radio-group/__tests__/radio-group.test.tsx +++ b/src/radio-group/__tests__/radio-group.test.tsx @@ -11,7 +11,7 @@ import { import '../../__a11y__/to-validate-a11y'; import RadioGroup, { RadioGroupProps } from '../../../lib/components/radio-group'; import createWrapper from '../../../lib/components/test-utils/dom'; -import RadioButtonWrapper from '../../../lib/components/test-utils/dom/radio-group/radio-button'; +import RadioButtonWrapper from '../../../lib/components/test-utils/dom/radio-button'; import customCssProps from '../../internal/generated/custom-css-properties'; import abstractSwitchStyles from '../../../lib/components/internal/components/abstract-switch/styles.css.js'; diff --git a/src/radio-group/interfaces.ts b/src/radio-group/interfaces.ts index 1db65fe3b3..6de71db76c 100644 --- a/src/radio-group/interfaces.ts +++ b/src/radio-group/interfaces.ts @@ -88,7 +88,12 @@ export namespace RadioGroupProps { value: string; } - export type Ref = RadioButtonProps.Ref; + export interface Ref { + /** + * Sets input focus onto the UI control. + */ + focus(): void; + } export type Style = RadioButtonProps.Style; } diff --git a/src/radio-group/internal.tsx b/src/radio-group/internal.tsx index 6a07bd36dc..d0011fef7a 100644 --- a/src/radio-group/internal.tsx +++ b/src/radio-group/internal.tsx @@ -79,11 +79,7 @@ const InternalRadioGroup = React.forwardRef( value={item.value} description={item.description} disabled={item.disabled} - onChange={({ detail }) => { - if (onChange && detail.checked) { - fireNonCancelableEvent(onChange, { value: item.value }); - } - }} + onSelect={() => fireNonCancelableEvent(onChange, { value: item.value })} controlId={item.controlId} readOnly={readOnly} style={style} diff --git a/src/table/selection/selection-control.tsx b/src/table/selection/selection-control.tsx index 1176f9ad82..b4a466cede 100644 --- a/src/table/selection/selection-control.tsx +++ b/src/table/selection/selection-control.tsx @@ -37,6 +37,7 @@ export function SelectionControl({ rowIndex, itemKey, verticalAlign = 'middle', + onChange, ...sharedProps }: SelectionControlProps) { const controlId = useUniqueId(); @@ -84,13 +85,14 @@ export function SelectionControl({ const selector = isMultiSelection ? ( ) : ( - + ); return ( diff --git a/src/test-utils/dom/collection-preferences/page-size-preference.ts b/src/test-utils/dom/collection-preferences/page-size-preference.ts index 4b9c0750ab..583fc5f06f 100644 --- a/src/test-utils/dom/collection-preferences/page-size-preference.ts +++ b/src/test-utils/dom/collection-preferences/page-size-preference.ts @@ -3,8 +3,8 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; import FormFieldWrapper from '../form-field'; +import RadioButtonWrapper from '../radio-button'; import RadioGroupWrapper from '../radio-group'; -import RadioButtonWrapper from '../radio-group/radio-button'; import styles from '../../../collection-preferences/styles.selectors.js'; diff --git a/src/test-utils/dom/radio-group/radio-button.ts b/src/test-utils/dom/radio-button/index.ts similarity index 100% rename from src/test-utils/dom/radio-group/radio-button.ts rename to src/test-utils/dom/radio-button/index.ts diff --git a/src/test-utils/dom/radio-group/index.ts b/src/test-utils/dom/radio-group/index.ts index 9f43774326..c36c8beb99 100644 --- a/src/test-utils/dom/radio-group/index.ts +++ b/src/test-utils/dom/radio-group/index.ts @@ -3,7 +3,7 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; import { escapeSelector } from '@cloudscape-design/test-utils-core/utils'; -import RadioButtonWrapper from './radio-button'; +import RadioButtonWrapper from '../radio-button'; import radioButtonStyles from '../../../internal/components/radio-button/test-classes/styles.selectors.js'; import styles from '../../../radio-group/test-classes/styles.selectors.js'; diff --git a/src/test-utils/dom/tiles/tile.ts b/src/test-utils/dom/tiles/tile.ts index 7320da5262..2badb890e3 100644 --- a/src/test-utils/dom/tiles/tile.ts +++ b/src/test-utils/dom/tiles/tile.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; -import RadioButtonWrapper from '../radio-group/radio-button'; +import RadioButtonWrapper from '../radio-button'; import styles from '../../../tiles/styles.selectors.js';