From d0d9758f77635c839d115bfe6a5f12f220bdc17f Mon Sep 17 00:00:00 2001 From: Adam Kudrna Date: Tue, 3 Dec 2024 13:47:14 +0100 Subject: [PATCH] Allow check fields and selectable fields to render as required #487 Users may find themselves in a situation where the input is not required (i.e. making the input checked), but they also don't want to render the field as optional because not choosing an option can be perfectly valid. For this case, there is the `renderAsRequired` prop. This affects `CheckboxField`, `Radio`, `SelectField`, and `Toggle`. Closes #487 --- .../CheckboxField/CheckboxField.jsx | 10 +- src/components/CheckboxField/README.md | 75 +++++++++++++ .../__tests__/CheckboxField.test.jsx | 2 + src/components/Radio/README.md | 103 ++++++++++++++++++ src/components/Radio/Radio.jsx | 10 +- src/components/Radio/Radio.module.scss | 4 + src/components/Radio/__tests__/Radio.test.jsx | 2 + src/components/SelectField/README.md | 103 ++++++++++++++++++ src/components/SelectField/SelectField.jsx | 10 +- .../__tests__/SelectField.test.jsx | 2 + src/components/Toggle/README.md | 74 +++++++++++++ src/components/Toggle/Toggle.jsx | 10 +- .../Toggle/__tests__/Toggle.test.jsx | 2 + src/docs/customize/theming/forms.md | 23 +++- src/styles/tools/form-fields/_foundation.scss | 10 +- tests/propTests/renderAsRequiredPropTest.js | 10 ++ 16 files changed, 435 insertions(+), 15 deletions(-) create mode 100644 tests/propTests/renderAsRequiredPropTest.js diff --git a/src/components/CheckboxField/CheckboxField.jsx b/src/components/CheckboxField/CheckboxField.jsx index 2c35432d..e23e521c 100644 --- a/src/components/CheckboxField/CheckboxField.jsx +++ b/src/components/CheckboxField/CheckboxField.jsx @@ -15,6 +15,7 @@ export const CheckboxField = React.forwardRef((props, ref) => { isLabelVisible, label, labelPosition, + renderAsRequired, required, validationState, validationText, @@ -30,7 +31,7 @@ export const CheckboxField = React.forwardRef((props, ref) => { context && context.layout === 'horizontal' ? styles.isRootLayoutHorizontal : styles.isRootLayoutVertical, labelPosition === 'before' && styles.hasRootLabelBefore, disabled && styles.isRootDisabled, - required && styles.isRootRequired, + (renderAsRequired || required) && styles.isRootRequired, getRootValidationStateClassName(validationState, styles), )} htmlFor={id} @@ -82,6 +83,7 @@ CheckboxField.defaultProps = { id: undefined, isLabelVisible: true, labelPosition: 'after', + renderAsRequired: false, required: false, validationState: null, validationText: null, @@ -120,7 +122,11 @@ CheckboxField.propTypes = { */ labelPosition: PropTypes.oneOf(['before', 'after']), /** - * If `true`, the input will be required. + * If `true`, the input will be rendered as if it was required. + */ + renderAsRequired: PropTypes.bool, + /** + * If `true`, the input will be made and rendered as required, regardless of the `renderAsRequired` prop. */ required: PropTypes.bool, /** diff --git a/src/components/CheckboxField/README.md b/src/components/CheckboxField/README.md index 51594d3e..aa7bdf04 100644 --- a/src/components/CheckboxField/README.md +++ b/src/components/CheckboxField/README.md @@ -186,6 +186,81 @@ React.createElement(() => { }); ``` +### Required State + +The required state indicates that the input is mandatory. Required fields +display an asterisk `*` after the label by default. + +```docoff-react-preview +React.createElement(() => { + const [agree, setAgree] = React.useState(true); + return ( + setAgree(!agree)} + required + /> + ); +}); +``` + +#### Styling the Required State + +All form fields in React UI can be +[styled](/docs/customize/theming/forms/#required-state) +to indicate the required state. + +However, you may find yourself in a situation where a form field is valid in +both checked and unchecked states, for example to turn on or off a feature. +If your project uses the label color as the primary means to indicate the +required state of input fields and the usual asterisk `*` is omitted, you may +want to keep the label color consistent for both states to avoid confusion. + +For this edge case, there is the `renderAsRequired` prop: + +```docoff-react-preview +React.createElement(() => { + const [optional, setOptional] = React.useState(false); + const [renderAsRequired, setRenderAsRequired] = React.useState(false); + return ( + + +
+ setOptional(!optional)} + /> + setRenderAsRequired(!renderAsRequired)} + renderAsRequired + /> +
+
+ ); +}); +``` + +It renders the field as if it was required, but doesn't add the `required` +attribute to the actual input. + ### Disabled State Disabled state makes the input unavailable. diff --git a/src/components/CheckboxField/__tests__/CheckboxField.test.jsx b/src/components/CheckboxField/__tests__/CheckboxField.test.jsx index 69da448f..a7ced2b8 100644 --- a/src/components/CheckboxField/__tests__/CheckboxField.test.jsx +++ b/src/components/CheckboxField/__tests__/CheckboxField.test.jsx @@ -11,6 +11,7 @@ import { helpTextPropTest } from '../../../../tests/propTests/helpTextPropTest'; import { formLayoutProviderTest } from '../../../../tests/providerTests/formLayoutProviderTest'; import { isLabelVisibleTest } from '../../../../tests/propTests/isLabelVisibleTest'; import { labelPropTest } from '../../../../tests/propTests/labelPropTest'; +import { renderAsRequiredPropTest } from '../../../../tests/propTests/renderAsRequiredPropTest'; import { requiredPropTest } from '../../../../tests/propTests/requiredPropTest'; import { validationStatePropTest } from '../../../../tests/propTests/validationStatePropTest'; import { validationTextPropTest } from '../../../../tests/propTests/validationTextPropTest'; @@ -43,6 +44,7 @@ describe('rendering', () => { ], ...isLabelVisibleTest(), ...labelPropTest(), + ...renderAsRequiredPropTest, ...requiredPropTest, ...validationStatePropTest, ...validationTextPropTest, diff --git a/src/components/Radio/README.md b/src/components/Radio/README.md index a5b317e8..9d8447df 100644 --- a/src/components/Radio/README.md +++ b/src/components/Radio/README.md @@ -237,6 +237,109 @@ have. }) ``` +### Required State + +The required state indicates that the input is mandatory. + +```docoff-react-preview +React.createElement(() => { + const [fruit, setFruit] = React.useState('apple'); + return ( + setFruit(e.target.value)} + options={[ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Grapefruit', + value: 'grapefruit', + }, + ]} + value={fruit} + required + /> + ); +}) +``` + +#### Styling the Required State + +All form fields in React UI can be +[styled](/docs/customize/theming/forms/#required-state) +to indicate the required state. + +However, you may find yourself in a situation where a form field is valid in +both selected and unselected states, for example to turn on or off a feature. +If your project uses the label color as the primary means to indicate the +required state of input fields and the usual asterisk `*` is omitted, you may +want to keep the label color consistent for both states to avoid confusion. + +For this edge case, there is the `renderAsRequired` prop: + +```docoff-react-preview +React.createElement(() => { + const [fruit, setFruit] = React.useState('apple'); + const options = [ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Grapefruit', + value: 'grapefruit', + }, + ]; + return ( + + +
+ setFruit(e.target.value)} + options={options} + value={fruit} + /> + setFruit(e.target.value)} + options={options} + value={fruit} + renderAsRequired + /> +
+
+ ); +}) +``` + +It renders the field as if it was required, but doesn't add the `required` +attribute to the actual input. + ### Disabled State It's possible to disable just some options or the whole set. diff --git a/src/components/Radio/Radio.jsx b/src/components/Radio/Radio.jsx index be5248d6..19f080c6 100644 --- a/src/components/Radio/Radio.jsx +++ b/src/components/Radio/Radio.jsx @@ -16,6 +16,7 @@ export const Radio = ({ label, layout, options, + renderAsRequired, required, validationState, validationText, @@ -33,7 +34,7 @@ export const Radio = ({ ? styles.isRootLayoutHorizontal : styles.isRootLayoutVertical, disabled && styles.isRootDisabled, - required && styles.isRootRequired, + (renderAsRequired || required) && styles.isRootRequired, getRootValidationStateClassName(validationState, styles), )} disabled={disabled} @@ -116,6 +117,7 @@ Radio.defaultProps = { id: undefined, isLabelVisible: true, layout: 'vertical', + renderAsRequired: false, required: false, validationState: null, validationText: null, @@ -181,7 +183,11 @@ Radio.propTypes = { ]), })).isRequired, /** - * If `true`, the input will be required. + * If `true`, the input will be rendered as if it was required. + */ + renderAsRequired: PropTypes.bool, + /** + * If `true`, the input will be made and rendered as required, regardless of the `renderAsRequired` prop. */ required: PropTypes.bool, /** diff --git a/src/components/Radio/Radio.module.scss b/src/components/Radio/Radio.module.scss index 4d7d0372..6cecf6a6 100644 --- a/src/components/Radio/Radio.module.scss +++ b/src/components/Radio/Radio.module.scss @@ -52,6 +52,10 @@ @include foundation.label-required(); } + .isRootRequired .optionLabel { + @include foundation.label-required($show-require-sign: false); + } + // States .isRootStateInvalid { @include variants.validation(invalid); diff --git a/src/components/Radio/__tests__/Radio.test.jsx b/src/components/Radio/__tests__/Radio.test.jsx index dc96f372..2f586c42 100644 --- a/src/components/Radio/__tests__/Radio.test.jsx +++ b/src/components/Radio/__tests__/Radio.test.jsx @@ -10,6 +10,7 @@ import { formLayoutProviderTest } from '../../../../tests/providerTests/formLayo import { isLabelVisibleTest } from '../../../../tests/propTests/isLabelVisibleTest'; import { labelPropTest } from '../../../../tests/propTests/labelPropTest'; import { layoutPropTest } from '../../../../tests/propTests/layoutPropTest'; +import { renderAsRequiredPropTest } from '../../../../tests/propTests/renderAsRequiredPropTest'; import { requiredPropTest } from '../../../../tests/propTests/requiredPropTest'; import { validationStatePropTest } from '../../../../tests/propTests/validationStatePropTest'; import { validationTextPropTest } from '../../../../tests/propTests/validationTextPropTest'; @@ -83,6 +84,7 @@ describe('rendering', () => { expect(within(rootElement).getByLabelText('option 2')).toBeDisabled(); }, ], + ...renderAsRequiredPropTest, ...requiredPropTest, ...validationStatePropTest, ...validationTextPropTest, diff --git a/src/components/SelectField/README.md b/src/components/SelectField/README.md index 1200e741..a61ef74a 100644 --- a/src/components/SelectField/README.md +++ b/src/components/SelectField/README.md @@ -592,6 +592,109 @@ React.createElement(() => { }) ``` +### Required State + +The required state indicates that the input is mandatory. + +```docoff-react-preview +React.createElement(() => { + const [fruit, setFruit] = React.useState('apple'); + return ( + setFruit(e.target.value)} + options={[ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Grapefruit', + value: 'grapefruit', + }, + ]} + value={fruit} + required + /> + ); +}); +``` + +#### Styling the Required State + +All form fields in React UI can be +[styled](/docs/customize/theming/forms/#required-state) +to indicate the required state. + +However, you may find yourself in a situation where a form field is valid in +both selected and unselected states, for example to turn on or off a feature. +If your project uses the label color as the primary means to indicate the +required state of input fields and the usual asterisk `*` is omitted, you may +want to keep the label color consistent for both states to avoid confusion. + +For this edge case, there is the `renderAsRequired` prop: + +```docoff-react-preview +React.createElement(() => { + const [fruit, setFruit] = React.useState('apple'); + const options = [ + { + label: 'Apple', + value: 'apple', + }, + { + label: 'Banana', + value: 'banana', + }, + { + label: 'Grapefruit', + value: 'grapefruit', + }, + ]; + return ( + + +
+ setFruit(e.target.value)} + options={options} + value={fruit} + /> + setFruit(e.target.value)} + options={options} + value={fruit} + renderAsRequired + /> +
+
+ ); +}); +``` + +It renders the field as if it was required, but doesn't add the `required` +attribute to the actual input. + ### Disabled State It's possible to disable just some options or the whole input. diff --git a/src/components/SelectField/SelectField.jsx b/src/components/SelectField/SelectField.jsx index 613b0eff..e8731e28 100644 --- a/src/components/SelectField/SelectField.jsx +++ b/src/components/SelectField/SelectField.jsx @@ -21,6 +21,7 @@ export const SelectField = React.forwardRef((props, ref) => { label, layout, options, + renderAsRequired, required, size, validationState, @@ -43,7 +44,7 @@ export const SelectField = React.forwardRef((props, ref) => { ? styles.isRootLayoutHorizontal : styles.isRootLayoutVertical, inputGroupContext && styles.isRootGrouped, - required && styles.isRootRequired, + (renderAsRequired || required) && styles.isRootRequired, getRootSizeClassName( resolveContextOrProp(inputGroupContext && inputGroupContext.size, size), styles, @@ -136,6 +137,7 @@ SelectField.defaultProps = { id: undefined, isLabelVisible: true, layout: 'vertical', + renderAsRequired: false, required: false, size: 'medium', validationState: null, @@ -227,7 +229,11 @@ SelectField.propTypes = { })), ]).isRequired, /** - * If `true`, the input will be required. + * If `true`, the input will be rendered as if it was required. + */ + renderAsRequired: PropTypes.bool, + /** + * If `true`, the input will be made and rendered as required, regardless of the `renderAsRequired` prop. */ required: PropTypes.bool, /** diff --git a/src/components/SelectField/__tests__/SelectField.test.jsx b/src/components/SelectField/__tests__/SelectField.test.jsx index 96a9e596..8b2d7dda 100644 --- a/src/components/SelectField/__tests__/SelectField.test.jsx +++ b/src/components/SelectField/__tests__/SelectField.test.jsx @@ -14,6 +14,7 @@ import { formLayoutProviderTest } from '../../../../tests/providerTests/formLayo import { isLabelVisibleTest } from '../../../../tests/propTests/isLabelVisibleTest'; import { labelPropTest } from '../../../../tests/propTests/labelPropTest'; import { layoutPropTest } from '../../../../tests/propTests/layoutPropTest'; +import { renderAsRequiredPropTest } from '../../../../tests/propTests/renderAsRequiredPropTest'; import { requiredPropTest } from '../../../../tests/propTests/requiredPropTest'; import { sizePropTest } from '../../../../tests/propTests/sizePropTest'; import { validationStatePropTest } from '../../../../tests/propTests/validationStatePropTest'; @@ -107,6 +108,7 @@ describe('rendering', () => { expect(within(rootElement).getByText('option 4')).toHaveAttribute('id', 'id__item__key'); }, ], + ...renderAsRequiredPropTest, ...requiredPropTest, ...sizePropTest, ...validationStatePropTest, diff --git a/src/components/Toggle/README.md b/src/components/Toggle/README.md index f1373746..3eac252c 100644 --- a/src/components/Toggle/README.md +++ b/src/components/Toggle/README.md @@ -163,6 +163,80 @@ React.createElement(() => { }); ``` +### Required State + +The required state indicates that the input is mandatory. + +```docoff-react-preview +React.createElement(() => { + const [studioQuality, setStudioQuality] = React.useState(true); + return ( + setStudioQuality(!studioQuality)} + required + /> + ); +}); +``` + +#### Styling the Required State + +All form fields in React UI can be +[styled](/docs/customize/theming/forms/#required-state) +to indicate the required state. + +However, you may find yourself in a situation where a form field is valid in +both checked and unchecked states, for example to turn on or off a feature. +If your project uses the label color as the primary means to indicate the +required state of input fields and the usual asterisk `*` is omitted, you may +want to keep the label color consistent for both states to avoid confusion. + +For this edge case, there is the `renderAsRequired` prop: + +```docoff-react-preview +React.createElement(() => { + const [optional, setOptional] = React.useState(false); + const [renderAsRequired, setRenderAsRequired] = React.useState(false); + return ( + + +
+ setOptional(!optional)} + /> + setRenderAsRequired(!renderAsRequired)} + renderAsRequired + /> +
+
+ ); +}); +``` + +It renders the field as if it was required, but doesn't add the `required` +attribute to the actual input. + ### Disabled State Disabled state makes the input unavailable. diff --git a/src/components/Toggle/Toggle.jsx b/src/components/Toggle/Toggle.jsx index 77d1fd0d..44ff357c 100644 --- a/src/components/Toggle/Toggle.jsx +++ b/src/components/Toggle/Toggle.jsx @@ -15,6 +15,7 @@ export const Toggle = React.forwardRef((props, ref) => { isLabelVisible, label, labelPosition, + renderAsRequired, required, validationState, validationText, @@ -31,7 +32,7 @@ export const Toggle = React.forwardRef((props, ref) => { context && context.layout === 'horizontal' ? styles.isRootLayoutHorizontal : styles.isRootLayoutVertical, labelPosition === 'before' && styles.hasRootLabelBefore, disabled && styles.isRootDisabled, - required && styles.isRootRequired, + (required || renderAsRequired) && styles.isRootRequired, getRootValidationStateClassName(validationState, styles), )} htmlFor={id} @@ -84,6 +85,7 @@ Toggle.defaultProps = { id: undefined, isLabelVisible: true, labelPosition: 'after', + renderAsRequired: false, required: false, validationState: null, validationText: null, @@ -120,7 +122,11 @@ Toggle.propTypes = { */ labelPosition: PropTypes.oneOf(['before', 'after']), /** - * If `true`, the input will be required. + * If `true`, the input will be rendered as if it was required. + */ + renderAsRequired: PropTypes.bool, + /** + * If `true`, the input will be made and rendered as required, regardless of the `renderAsRequired` prop. */ required: PropTypes.bool, /** diff --git a/src/components/Toggle/__tests__/Toggle.test.jsx b/src/components/Toggle/__tests__/Toggle.test.jsx index f5bfe13c..2feff337 100644 --- a/src/components/Toggle/__tests__/Toggle.test.jsx +++ b/src/components/Toggle/__tests__/Toggle.test.jsx @@ -12,6 +12,7 @@ import { helpTextPropTest } from '../../../../tests/propTests/helpTextPropTest'; import { formLayoutProviderTest } from '../../../../tests/providerTests/formLayoutProviderTest'; import { isLabelVisibleTest } from '../../../../tests/propTests/isLabelVisibleTest'; import { labelPropTest } from '../../../../tests/propTests/labelPropTest'; +import { renderAsRequiredPropTest } from '../../../../tests/propTests/renderAsRequiredPropTest'; import { requiredPropTest } from '../../../../tests/propTests/requiredPropTest'; import { validationStatePropTest } from '../../../../tests/propTests/validationStatePropTest'; import { validationTextPropTest } from '../../../../tests/propTests/validationTextPropTest'; @@ -51,6 +52,7 @@ describe('rendering', () => { { labelPosition: 'after' }, (rootElement) => expect(rootElement).not.toHaveClass('hasRootLabelBefore'), ], + ...renderAsRequiredPropTest, ...requiredPropTest, ...validationStatePropTest, ...validationTextPropTest, diff --git a/src/docs/customize/theming/forms.md b/src/docs/customize/theming/forms.md index 0c259956..2bb5f7d4 100644 --- a/src/docs/customize/theming/forms.md +++ b/src/docs/customize/theming/forms.md @@ -23,9 +23,6 @@ The following theme options define basic appearance of all form fields. | `--rui-FormField__help-text__font-size` | Help text font size | | `--rui-FormField__help-text__font-style` | Help text font style, e.g. italic | | `--rui-FormField__help-text__color` | Help text color | -| `--rui-FormField--required__label__color` | Color of required input labels | -| `--rui-FormField--required__sign` | Text appended to required input labels | -| `--rui-FormField--required__sign__color` | Color of text appended to required input labels | ## Horizontal Layout @@ -599,6 +596,26 @@ React.createElement(() => { }); ``` +## Required State + +Theming options for required fields are shared by all form components. + +| Custom Property | Description | +|------------------------------------------------------|--------------------------------------------------------------| +| `--rui-FormField--required__label__color` | Color of required input labels | +| `--rui-FormField--required__sign` | Text appended to required input labels | +| `--rui-FormField--required__sign__color` | Color of text appended to required input labels | + +👉 Please note that selected components can be rendered as required by setting +the `renderAsRequired` prop to `true`. This is useful when +`--rui-FormField--required__label__color` is used to indicate the required state +of input fields, but you want to bypass it for inputs like feature toggles. +This applies to +[CheckboxField](/components/CheckboxField/#styling-the-required-state), +[Radio](/components/Radio/#styling-the-required-state), +[SelectField](/components/SelectField/#styling-the-required-state), +and [Toggle](/components/Toggle/#styling-the-required-state). + ## Disabled State By default, all disabled form fields are semi-transparent and change mouse diff --git a/src/styles/tools/form-fields/_foundation.scss b/src/styles/tools/form-fields/_foundation.scss index f23f127d..7c30f6ff 100644 --- a/src/styles/tools/form-fields/_foundation.scss +++ b/src/styles/tools/form-fields/_foundation.scss @@ -18,12 +18,14 @@ color: var(--rui-local-surrounding-text-color, #{theme.$label-color}); } -@mixin label-required() { +@mixin label-required($show-require-sign: true) { color: var(--rui-local-surrounding-text-color, #{theme.$required-label-color}); - &::after { - content: theme.$required-sign; - color: theme.$required-sign-color; + @if $show-require-sign { + &::after { + content: theme.$required-sign; + color: theme.$required-sign-color; + } } } diff --git a/tests/propTests/renderAsRequiredPropTest.js b/tests/propTests/renderAsRequiredPropTest.js new file mode 100644 index 00000000..2942ddc4 --- /dev/null +++ b/tests/propTests/renderAsRequiredPropTest.js @@ -0,0 +1,10 @@ +export const renderAsRequiredPropTest = [ + [ + { renderAsRequired: true }, + (rootElement) => expect(rootElement).toHaveClass('isRootRequired'), + ], + [ + { renderAsRequired: false }, + (rootElement) => expect(rootElement).not.toHaveClass('isRootRequired'), + ], +];