From be00028c4ac3928f904324a551644db875ee34bd Mon Sep 17 00:00:00 2001
From: Jesper Hodge <19345795+jesperhodge@users.noreply.github.com>
Date: Mon, 11 Mar 2024 12:59:05 -0400
Subject: [PATCH] Fix Problem Type Select (#460)
* fix: override selectablebox with copy of version from edx paragon 21.5.6
* chore: remove console logs
* fix: lint
* chore: update snapshots
---
.../content/ProblemTypeSelect.jsx | 4 +-
.../ProblemTypeSelect.test.jsx.snap | 25 --
.../SelectableBox/FormCheckbox.jsx | 145 ++++++++++++
.../SelectableBox/FormCheckboxSet.jsx | 68 ++++++
.../SelectableBox/FormCheckboxSetContext.jsx | 10 +-
.../SelectableBox/FormControlFeedback.jsx | 56 +++++
.../SelectableBox/FormControlSet.jsx | 40 ++++
.../SelectableBox/FormGroupContext.jsx | 121 ++++++++++
.../SelectableBox/FormLabel.jsx | 42 ++++
.../SelectableBox/FormRadio.jsx | 110 +++++++++
.../SelectableBox/FormRadioSet.jsx | 68 ++++++
.../SelectableBox/FormRadioSetContext.jsx | 15 +-
.../SelectableBox/FormText.jsx | 115 ++++++++++
.../sharedComponents/SelectableBox/README.md | 215 ++++++++++++++++++
.../SelectableBox/SelectableBoxSet.jsx | 22 +-
.../SelectableBox/_variables.scss | 5 +
.../SelectableBox/constants.js | 17 ++
.../SelectableBox/fieldUtils.js | 70 ++++++
.../sharedComponents/SelectableBox/index.jsx | 18 +-
.../sharedComponents/SelectableBox/index.scss | 54 +++++
.../sharedComponents/SelectableBox/newId.js | 8 +
.../tests/SelectableBox.test.jsx | 124 ++++++++++
.../tests/SelectableBoxSet.test.jsx | 107 +++++++++
.../__snapshots__/SelectableBox.test.jsx.snap | 22 ++
.../SelectableBoxSet.test.jsx.snap | 57 +++++
.../{getInputType.jsx => utils.js} | 13 +-
26 files changed, 1468 insertions(+), 83 deletions(-)
create mode 100644 src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/FormControlSet.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/FormLabel.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/FormRadio.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/FormText.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/README.md
create mode 100644 src/editors/sharedComponents/SelectableBox/_variables.scss
create mode 100644 src/editors/sharedComponents/SelectableBox/constants.js
create mode 100644 src/editors/sharedComponents/SelectableBox/fieldUtils.js
create mode 100644 src/editors/sharedComponents/SelectableBox/index.scss
create mode 100644 src/editors/sharedComponents/SelectableBox/newId.js
create mode 100644 src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx
create mode 100644 src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap
create mode 100644 src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap
rename src/editors/sharedComponents/SelectableBox/{getInputType.jsx => utils.js} (63%)
diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx
index 9cbc218a7..b1c71ddcc 100644
--- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx
+++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx
@@ -3,9 +3,7 @@ import PropTypes from 'prop-types';
import { Button, Container } from '@openedx/paragon';
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
-/* SelectableBox from Paragon currently has a bug as it misses a ContextProvider.
-This is a temporary fix by overriding it with our own copy that includes the ContextProvider. */
-// TODO: Replace this import with a paragon import once the bug is fixed and delete our copy of SelectableBox.
+// SelectableBox in paragon has a bug only visible on stage where you can't change selection. So we override it
import SelectableBox from '../../../../../sharedComponents/SelectableBox';
import { ProblemTypes, ProblemTypeKeys, AdvanceProblemKeys } from '../../../../../data/constants/problem';
import messages from './messages';
diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap
index 596d3a87a..1b34450fa 100644
--- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap
+++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap
@@ -25,7 +25,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="multiplechoiceresponse"
>
@@ -41,7 +40,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="choiceresponse"
>
@@ -57,7 +55,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="optionresponse"
>
@@ -73,7 +70,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="numericalresponse"
>
@@ -89,7 +85,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="stringresponse"
>
@@ -135,7 +130,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="multiplechoiceresponse"
>
@@ -151,7 +145,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="choiceresponse"
>
@@ -167,7 +160,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="optionresponse"
>
@@ -183,7 +175,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="numericalresponse"
>
@@ -199,7 +190,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="stringresponse"
>
@@ -245,7 +235,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="multiplechoiceresponse"
>
@@ -261,7 +250,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="choiceresponse"
>
@@ -277,7 +265,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="optionresponse"
>
@@ -293,7 +280,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="numericalresponse"
>
@@ -309,7 +295,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="stringresponse"
>
@@ -355,7 +340,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="multiplechoiceresponse"
>
@@ -371,7 +355,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="choiceresponse"
>
@@ -387,7 +370,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="optionresponse"
>
@@ -403,7 +385,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="numericalresponse"
>
@@ -419,7 +400,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="stringresponse"
>
@@ -465,7 +445,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="multiplechoiceresponse"
>
@@ -481,7 +460,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="choiceresponse"
>
@@ -497,7 +475,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="optionresponse"
>
@@ -513,7 +490,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="numericalresponse"
>
@@ -529,7 +505,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = `
isInvalid={false}
onClick={[Function]}
onFocus={[Function]}
- showActiveBoxState={true}
type="radio"
value="stringresponse"
>
diff --git a/src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx b/src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx
new file mode 100644
index 000000000..d2e1951ba
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useCheckboxSetContext } from './FormCheckboxSetContext';
+import { FormGroupContextProvider, useFormGroupContext } from './FormGroupContext';
+import FormLabel from './FormLabel';
+import FormControlFeedback from './FormControlFeedback';
+
+const CheckboxControl = React.forwardRef(
+ ({ isIndeterminate, ...props }, ref) => {
+ const { getCheckboxControlProps, hasCheckboxSetProvider } = useCheckboxSetContext();
+ const defaultRef = React.useRef();
+ const resolvedRef = ref || defaultRef;
+ const { getControlProps } = useFormGroupContext();
+ let checkboxProps = getControlProps({
+ ...props,
+ className: classNames('pgn__form-checkbox-input', props.className),
+ });
+
+ if (hasCheckboxSetProvider) {
+ checkboxProps = getCheckboxControlProps(checkboxProps);
+ }
+
+ React.useEffect(() => {
+ // this if(resolvedRef.current) prevents console errors in testing
+ if (resolvedRef.current) {
+ resolvedRef.current.indeterminate = isIndeterminate;
+ }
+ }, [resolvedRef, isIndeterminate]);
+
+ return (
+
+ );
+ },
+);
+
+CheckboxControl.propTypes = {
+ /** Specifies whether the checkbox should be rendered in indeterminate state. */
+ isIndeterminate: PropTypes.bool,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+};
+
+CheckboxControl.defaultProps = {
+ isIndeterminate: false,
+ className: undefined,
+};
+
+const FormCheckbox = React.forwardRef(({
+ children,
+ className,
+ controlClassName,
+ labelClassName,
+ description,
+ isInvalid,
+ isValid,
+ controlAs,
+ floatLabelLeft,
+ ...props
+}, ref) => {
+ const { hasCheckboxSetProvider } = useCheckboxSetContext();
+ const { hasFormGroupProvider, useSetIsControlGroupEffect, getControlProps } = useFormGroupContext();
+ useSetIsControlGroupEffect(true);
+ const shouldActAsGroup = hasFormGroupProvider && !hasCheckboxSetProvider;
+ const groupProps = shouldActAsGroup ? {
+ ...getControlProps({}),
+ role: 'group',
+ } : {};
+
+ const control = React.createElement(controlAs, { ...props, className: controlClassName, ref });
+ return (
+
+
+ {control}
+
+
+ {children}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ );
+});
+
+FormCheckbox.propTypes = {
+ /** Specifies id of the FormCheckbox component. */
+ id: PropTypes.string,
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies class name for control component. */
+ controlClassName: PropTypes.string,
+ /** Specifies class name for label component. */
+ labelClassName: PropTypes.string,
+ /** Specifies description to show under the checkbox. */
+ description: PropTypes.node,
+ /** Specifies whether to display checkbox in invalid state, this affects styling. */
+ isInvalid: PropTypes.bool,
+ /** Specifies whether to display checkbox in valid state, this affects styling. */
+ isValid: PropTypes.bool,
+ /** Specifies control element. */
+ controlAs: PropTypes.elementType,
+ /** Specifies whether the floating label should be aligned to the left. */
+ floatLabelLeft: PropTypes.bool,
+ /** Specifies whether the `FormCheckbox` is disabled. */
+ disabled: PropTypes.bool,
+};
+
+FormCheckbox.defaultProps = {
+ id: undefined,
+ className: undefined,
+ controlClassName: undefined,
+ labelClassName: undefined,
+ description: undefined,
+ isInvalid: false,
+ isValid: false,
+ controlAs: CheckboxControl,
+ floatLabelLeft: false,
+ disabled: false,
+};
+
+export { CheckboxControl };
+export default FormCheckbox;
diff --git a/src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx b/src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx
new file mode 100644
index 000000000..2d90cada0
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useFormGroupContext } from './FormGroupContext';
+import { FormCheckboxSetContextProvider } from './FormCheckboxSetContext';
+import FormControlSet from './FormControlSet';
+
+const FormCheckboxSet = ({
+ children,
+ name,
+ value,
+ defaultValue,
+ isInline,
+ onChange,
+ onFocus,
+ onBlur,
+ ...props
+}) => {
+ const { getControlProps, useSetIsControlGroupEffect } = useFormGroupContext();
+ useSetIsControlGroupEffect(true);
+ const controlProps = getControlProps(props);
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+FormCheckboxSet.propTypes = {
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies name for the component. */
+ name: PropTypes.string.isRequired,
+ /** Specifies values for the checkboxes. */
+ value: PropTypes.arrayOf(PropTypes.string),
+ /** Specifies default values for the checkboxes. */
+ defaultValue: PropTypes.arrayOf(PropTypes.string),
+ /** Specifies whether to display components with inline styling. */
+ isInline: PropTypes.bool,
+ /** Specifies onChange event handler. */
+ onChange: PropTypes.func,
+ /** Specifies onFocus event handler. */
+ onFocus: PropTypes.func,
+ /** Specifies onBlur event handler. */
+ onBlur: PropTypes.func,
+};
+
+FormCheckboxSet.defaultProps = {
+ className: undefined,
+ value: undefined,
+ defaultValue: undefined,
+ isInline: false,
+ onChange: undefined,
+ onFocus: undefined,
+ onBlur: undefined,
+};
+
+export default FormCheckboxSet;
diff --git a/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx b/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx
index 2cd951d5c..1dd45b524 100644
--- a/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx
+++ b/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx
@@ -1,14 +1,6 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
-
-const callAllHandlers = (...handlers) => {
- const unifiedEventHandler = (event) => {
- handlers
- .filter(handler => typeof handler === 'function')
- .forEach(handler => handler(event));
- };
- return unifiedEventHandler;
-};
+import { callAllHandlers } from './fieldUtils';
const identityFn = props => props;
diff --git a/src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx b/src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx
new file mode 100644
index 000000000..b4e211218
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useFormGroupContext } from './FormGroupContext';
+import FormText, { resolveTextType } from './FormText';
+
+const FormControlFeedback = ({ children, ...props }) => {
+ const { getDescriptorProps, isInvalid, isValid } = useFormGroupContext();
+ const descriptorProps = getDescriptorProps(props);
+ const className = classNames('pgn__form-control-description', props.className);
+ const textType = props.type || resolveTextType({ isInvalid, isValid });
+ return (
+
+ {children}
+
+ );
+};
+
+const FEEDBACK_TYPES = [
+ 'default',
+ 'valid',
+ 'invalid',
+ 'warning',
+ 'criteria-empty',
+ 'criteria-valid',
+ 'criteria-invalid',
+];
+
+FormControlFeedback.propTypes = {
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies whether to show an icon next to the text. */
+ hasIcon: PropTypes.bool,
+ /** Specifies feedback type, this affects styling. */
+ type: PropTypes.oneOf(FEEDBACK_TYPES),
+ /** Specifies icon to show, will only be shown if `hasIcon` prop is set to `true`. */
+ icon: PropTypes.node,
+ /** Specifies whether to show feedback with muted styling. */
+ muted: PropTypes.bool,
+};
+
+FormControlFeedback.defaultProps = {
+ hasIcon: true,
+ type: undefined,
+ icon: undefined,
+ className: undefined,
+ muted: false,
+};
+
+export default FormControlFeedback;
diff --git a/src/editors/sharedComponents/SelectableBox/FormControlSet.jsx b/src/editors/sharedComponents/SelectableBox/FormControlSet.jsx
new file mode 100644
index 000000000..69052e767
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormControlSet.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+const FormControlSet = ({
+ as,
+ className,
+ isInline,
+ children,
+ ...props
+}) => React.createElement(as, {
+ className: classNames(
+ className,
+ {
+ 'pgn__form-control-set': !isInline,
+ 'pgn__form-control-set-inline': isInline,
+ },
+ ),
+ ...props,
+}, children);
+
+FormControlSet.propTypes = {
+ /** Specifies the base element */
+ as: PropTypes.elementType,
+ /** A class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies whether the component should be displayed with inline styling. */
+ isInline: PropTypes.bool,
+ /** Specifies contents of the component. */
+ children: PropTypes.node,
+};
+
+FormControlSet.defaultProps = {
+ as: 'div',
+ className: undefined,
+ isInline: false,
+ children: null,
+};
+
+export default FormControlSet;
diff --git a/src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx b/src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx
new file mode 100644
index 000000000..af38a4f8a
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx
@@ -0,0 +1,121 @@
+import React, {
+ useState, useEffect, useMemo, useCallback,
+} from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import newId from './newId';
+import { useIdList, omitUndefinedProperties } from './fieldUtils';
+import { FORM_CONTROL_SIZES } from './constants';
+
+const identityFn = props => props;
+const noop = () => {};
+
+const FormGroupContext = React.createContext({
+ getControlProps: identityFn,
+ useSetIsControlGroupEffect: noop,
+ getLabelProps: identityFn,
+ getDescriptorProps: identityFn,
+ hasFormGroupProvider: false,
+});
+
+const useFormGroupContext = () => React.useContext(FormGroupContext);
+
+const useStateEffect = (initialState) => {
+ const [state, setState] = useState(initialState);
+ const useSetStateEffect = (newState) => {
+ useEffect(() => setState(newState), [newState]);
+ };
+ return [state, useSetStateEffect];
+};
+
+const FormGroupContextProvider = ({
+ children,
+ controlId: explicitControlId,
+ isInvalid,
+ isValid,
+ size,
+}) => {
+ const controlId = useMemo(() => explicitControlId || newId('form-field'), [explicitControlId]);
+ const [describedByIds, registerDescriptorId] = useIdList(controlId);
+ const [labelledByIds, registerLabelerId] = useIdList(controlId);
+ const [isControlGroup, useSetIsControlGroupEffect] = useStateEffect(false);
+
+ const getControlProps = useCallback((controlProps) => {
+ // labelledByIds from the list above should only be added to a control
+ // if it the control is a group. We prefer adding a condition here because:
+ // - Hooks cannot be called inside conditionals
+ // - The getLabelProps function below is forced to generate an id
+ // whether it is needed or not.
+ // - This is what allows consumers of Paragon to use
+ // interchangeably between ControlGroup type controls and regular Controls
+ const labelledByIdsForControl = isControlGroup ? labelledByIds : undefined;
+ return omitUndefinedProperties({
+ ...controlProps,
+ 'aria-describedby': classNames(controlProps['aria-describedby'], describedByIds) || undefined,
+ 'aria-labelledby': classNames(controlProps['aria-labelledby'], labelledByIdsForControl) || undefined,
+ id: controlId,
+ });
+ }, [
+ isControlGroup,
+ describedByIds,
+ labelledByIds,
+ controlId,
+ ]);
+
+ const getLabelProps = (labelProps) => {
+ const id = registerLabelerId(labelProps?.id);
+ if (isControlGroup) {
+ return { ...labelProps, id };
+ }
+ return { ...labelProps, htmlFor: controlId };
+ };
+
+ const getDescriptorProps = (descriptorProps) => {
+ const id = registerDescriptorId(descriptorProps?.id);
+ return { ...descriptorProps, id };
+ };
+
+ // eslint-disable-next-line react/jsx-no-constructed-context-values
+ const contextValue = {
+ getControlProps,
+ getLabelProps,
+ getDescriptorProps,
+ useSetIsControlGroupEffect,
+ isControlGroup,
+ controlId,
+ isInvalid,
+ isValid,
+ size,
+ hasFormGroupProvider: true,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+FormGroupContextProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+ controlId: PropTypes.string,
+ isInvalid: PropTypes.bool,
+ isValid: PropTypes.bool,
+ size: PropTypes.oneOf([
+ FORM_CONTROL_SIZES.SMALL,
+ FORM_CONTROL_SIZES.LARGE,
+ ]),
+};
+
+FormGroupContextProvider.defaultProps = {
+ controlId: undefined,
+ isInvalid: undefined,
+ isValid: undefined,
+ size: undefined,
+};
+
+export {
+ FormGroupContext,
+ FormGroupContextProvider,
+ useFormGroupContext,
+};
diff --git a/src/editors/sharedComponents/SelectableBox/FormLabel.jsx b/src/editors/sharedComponents/SelectableBox/FormLabel.jsx
new file mode 100644
index 000000000..7f45bb9bc
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormLabel.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useFormGroupContext } from './FormGroupContext';
+import { FORM_CONTROL_SIZES } from './constants';
+
+const FormLabel = ({ children, isInline, ...props }) => {
+ const { size, isControlGroup, getLabelProps } = useFormGroupContext();
+ const className = classNames(
+ 'pgn__form-label',
+ {
+ 'pgn__form-label-inline': isInline,
+ 'pgn__form-label-lg': size === FORM_CONTROL_SIZES.LARGE,
+ 'pgn__form-label-sm': size === FORM_CONTROL_SIZES.SMALL,
+ },
+ props.className,
+ );
+ const labelProps = getLabelProps({ ...props, className });
+ const componentType = isControlGroup ? 'p' : 'label';
+ return React.createElement(componentType, labelProps, children);
+};
+
+const SIZE_CHOICES = ['sm', 'lg'];
+
+FormLabel.propTypes = {
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies whether the component should be displayed with inline styling. */
+ isInline: PropTypes.bool,
+ /** Specifies size of the component. */
+ size: PropTypes.oneOf(SIZE_CHOICES),
+};
+
+FormLabel.defaultProps = {
+ isInline: false,
+ size: undefined,
+ className: undefined,
+};
+
+export default FormLabel;
diff --git a/src/editors/sharedComponents/SelectableBox/FormRadio.jsx b/src/editors/sharedComponents/SelectableBox/FormRadio.jsx
new file mode 100644
index 000000000..43e4133d6
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormRadio.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useRadioSetContext } from './FormRadioSetContext';
+import { FormGroupContextProvider, useFormGroupContext } from './FormGroupContext';
+import FormLabel from './FormLabel';
+import FormControlFeedback from './FormControlFeedback';
+
+const RadioControl = React.forwardRef((props, ref) => {
+ const { getControlProps } = useFormGroupContext();
+ const { getRadioControlProps, hasRadioSetProvider } = useRadioSetContext();
+ let radioProps = getControlProps({
+ ...props,
+ className: classNames('pgn__form-radio-input', props.className),
+ });
+
+ if (hasRadioSetProvider) {
+ radioProps = getRadioControlProps(radioProps);
+ }
+
+ const onChange = (...args) => {
+ if (radioProps.onChange) {
+ radioProps.onChange(...args);
+ }
+ };
+
+ return (
+
+ );
+});
+
+RadioControl.propTypes = {
+ className: PropTypes.string,
+};
+
+RadioControl.defaultProps = {
+ className: undefined,
+};
+
+const FormRadio = React.forwardRef(({
+ children,
+ className,
+ controlClassName,
+ labelClassName,
+ description,
+ isInvalid,
+ isValid,
+ ...props
+}, ref) => (
+
+
+
+
+
+ {children}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+));
+
+FormRadio.propTypes = {
+ /** Specifies id of the FormRadio component. */
+ id: PropTypes.string,
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies class name for control component. */
+ controlClassName: PropTypes.string,
+ /** Specifies class name for label component. */
+ labelClassName: PropTypes.string,
+ /** Specifies description to show under the radio's value. */
+ description: PropTypes.node,
+ /** Specifies whether to display component in invalid state, this affects styling. */
+ isInvalid: PropTypes.bool,
+ /** Specifies whether to display component in valid state, this affects styling. */
+ isValid: PropTypes.bool,
+ /** Specifies whether the `FormRadio` is disabled. */
+ disabled: PropTypes.bool,
+};
+
+FormRadio.defaultProps = {
+ id: undefined,
+ className: undefined,
+ controlClassName: undefined,
+ labelClassName: undefined,
+ description: undefined,
+ isInvalid: false,
+ isValid: false,
+ disabled: false,
+};
+
+export { RadioControl };
+export default FormRadio;
diff --git a/src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx b/src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx
new file mode 100644
index 000000000..05524ba20
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useFormGroupContext } from './FormGroupContext';
+import { FormRadioSetContextProvider } from './FormRadioSetContext';
+import FormControlSet from './FormControlSet';
+
+const FormRadioSet = ({
+ children,
+ name,
+ value,
+ defaultValue,
+ isInline,
+ onChange,
+ onFocus,
+ onBlur,
+ ...props
+}) => {
+ const { getControlProps, useSetIsControlGroupEffect } = useFormGroupContext();
+ useSetIsControlGroupEffect(true);
+ const controlProps = getControlProps(props);
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+FormRadioSet.propTypes = {
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** A class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies name for the component. */
+ name: PropTypes.string.isRequired,
+ /** Specifies values for the FormRadioSet. */
+ value: PropTypes.string,
+ /** Specifies default values. */
+ defaultValue: PropTypes.string,
+ /** Specifies whether the component should be displayed with inline styling. */
+ isInline: PropTypes.bool,
+ /** Specifies onChange event handler. */
+ onChange: PropTypes.func,
+ /** Specifies onFocus event handler. */
+ onFocus: PropTypes.func,
+ /** Specifies onBlur event handler. */
+ onBlur: PropTypes.func,
+};
+
+FormRadioSet.defaultProps = {
+ className: undefined,
+ value: undefined,
+ defaultValue: undefined,
+ isInline: false,
+ onChange: undefined,
+ onFocus: undefined,
+ onBlur: undefined,
+};
+
+export default FormRadioSet;
diff --git a/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx b/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx
index 99642c974..41716cadd 100644
--- a/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx
+++ b/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx
@@ -1,14 +1,6 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
-
-const callAllHandlers = (...handlers) => {
- const unifiedEventHandler = (event) => {
- handlers
- .filter(handler => typeof handler === 'function')
- .forEach(handler => handler(event));
- };
- return unifiedEventHandler;
-};
+import { callAllHandlers } from './fieldUtils';
const identityFn = props => props;
@@ -28,6 +20,9 @@ const FormRadioSetContextProvider = ({
value,
defaultValue,
}) => {
+ const handleChange = (...args) => {
+ onChange(...args);
+ };
const isControlled = !defaultValue && value !== undefined;
const getRadioControlProps = (radioProps) => ({
...radioProps,
@@ -37,7 +32,7 @@ const FormRadioSetContextProvider = ({
/* istanbul ignore next */
onFocus: radioProps.onFocus ? callAllHandlers(onFocus, radioProps.onFocus) : onFocus,
/* istanbul ignore next */
- onChange: radioProps.onChange ? callAllHandlers(onChange, radioProps.onChange) : onChange,
+ onChange: radioProps.onChange ? callAllHandlers(handleChange, radioProps.onChange) : onChange,
checked: isControlled ? value === radioProps.value : undefined,
defaultChecked: isControlled ? undefined : defaultValue === radioProps.value,
});
diff --git a/src/editors/sharedComponents/SelectableBox/FormText.jsx b/src/editors/sharedComponents/SelectableBox/FormText.jsx
new file mode 100644
index 000000000..f6469a151
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/FormText.jsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+// import Icon from '../Icon';
+// import {
+// Check, Close, Cancel, CheckCircle, RadioButtonUnchecked, WarningFilled,
+// } from '../../icons';
+
+import { FORM_TEXT_TYPES } from './constants';
+
+// const FORM_TEXT_ICONS = {
+// [FORM_TEXT_TYPES.DEFAULT]: null,
+// [FORM_TEXT_TYPES.VALID]: Check,
+// [FORM_TEXT_TYPES.INVALID]: Close,
+// [FORM_TEXT_TYPES.WARNING]: WarningFilled,
+// [FORM_TEXT_TYPES.CRITERIA_EMPTY]: RadioButtonUnchecked,
+// [FORM_TEXT_TYPES.CRITERIA_VALID]: CheckCircle,
+// [FORM_TEXT_TYPES.CRITERIA_INVALID]: Cancel,
+// };
+
+const resolveTextType = ({ isInvalid, isValid }) => {
+ if (isValid) {
+ return FORM_TEXT_TYPES.VALID;
+ }
+ if (isInvalid) {
+ return FORM_TEXT_TYPES.INVALID;
+ }
+ return FORM_TEXT_TYPES.DEFAULT;
+};
+
+// const FormTextIcon = ({ type, customIcon }) => {
+// if (customIcon) {
+// return customIcon;
+// }
+
+// const typeIcon = FORM_TEXT_ICONS[type];
+// if (typeIcon) {
+// return ;
+// }
+
+// return null;
+// };
+
+// FormTextIcon.propTypes = {
+// type: PropTypes.oneOf(Object.values(FORM_TEXT_TYPES)),
+// customIcon: PropTypes.node,
+// };
+
+// FormTextIcon.defaultProps = {
+// type: undefined,
+// customIcon: undefined,
+// };
+
+const FormText = ({
+ children, type, icon, muted, hasIcon, ...props
+}) => {
+ const className = classNames(
+ props.className,
+ 'pgn__form-text',
+ `pgn__form-text-${type}`,
+ {
+ 'text-muted': muted,
+ },
+ );
+
+ return (
+
+ {/* {hasIcon &&
} */}
+
+ {children}
+
+
+ );
+};
+
+const FORM_TEXT_TYPE_CHOICES = [
+ 'default',
+ 'valid',
+ 'invalid',
+ 'warning',
+ 'criteria-empty',
+ 'criteria-valid',
+ 'criteria-invalid',
+];
+
+FormText.propTypes = {
+ /** Specifies contents of the component. */
+ children: PropTypes.node.isRequired,
+ /** Specifies class name to append to the base element. */
+ className: PropTypes.string,
+ /** Specifies whether to show an icon next to the text. */
+ hasIcon: PropTypes.bool,
+ /** Specifies text type, this affects styling. */
+ type: PropTypes.oneOf(FORM_TEXT_TYPE_CHOICES),
+ /** Specifies icon to show, will only be shown if `hasIcon` prop is set to `true`. */
+ icon: PropTypes.node,
+ /** Specifies whether to show text with muted styling. */
+ muted: PropTypes.bool,
+};
+
+FormText.defaultProps = {
+ hasIcon: true,
+ type: 'default',
+ icon: undefined,
+ className: undefined,
+ muted: false,
+};
+
+export default FormText;
+export {
+ FORM_TEXT_TYPES,
+ // FORM_TEXT_ICONS,
+ // FormTextIcon,
+ resolveTextType,
+};
diff --git a/src/editors/sharedComponents/SelectableBox/README.md b/src/editors/sharedComponents/SelectableBox/README.md
new file mode 100644
index 000000000..4ab1f417d
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/README.md
@@ -0,0 +1,215 @@
+---
+title: 'SelectableBox'
+type: 'component'
+components:
+- SelectableBox
+- SelectableBoxSet
+categories:
+- Forms
+- Content
+status: 'New'
+designStatus: 'Done'
+devStatus: 'In progress'
+notes: |
+---
+
+A box that has selection states. It can be used as an alternative to a radio button or checkbox set.
+
+The ``SelectableBox`` can contain any kind of content as long as it is not clickable. In other words, there should be no clickable targets distinct from selection.
+
+## Basic Usage
+
+As ``Checkbox``
+
+```jsx live
+() => {
+ const type = 'checkbox';
+ const allCheeseOptions = ['swiss', 'cheddar', 'pepperjack'];
+ const [checkedCheeses, { add, remove, set, clear }] = useCheckboxSetValues(['swiss']);
+
+ const handleChange = e => {
+ e.target.checked ? add(e.target.value) : remove(e.target.value);
+ };
+
+ const isInvalid = () => checkedCheeses.includes('swiss');
+ const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth });
+
+ return (
+
+
+
+
+
It is my first SelectableBox
+
Swiss
+
+
+
+ Cheddar
+
+
+ Pepperjack
+
+
+
+ );
+}
+```
+
+## As Radio
+
+```jsx live
+() => {
+ const type = 'radio';
+ const [value, setValue] = useState('green');
+ const handleChange = e => setValue(e.target.value);
+ const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth });
+
+ return (
+
+
+
+
It is Red color
+
Select me
+
+
+
+ Green
+ Leaves and grass
+
+
+ Blue
+ The sky
+
+
+ );
+}
+```
+## As Checkbox
+As ``Checkbox`` with ``isIndeterminate``
+
+```jsx live
+() => {
+ const type = 'checkbox';
+ const allCheeseOptions = ['swiss', 'cheddar', 'pepperjack'];
+ const [checkedCheeses, { add, remove, set, clear }] = useCheckboxSetValues(['swiss']);
+
+ const allChecked = allCheeseOptions.every(value => checkedCheeses.includes(value));
+ const someChecked = allCheeseOptions.some(value => checkedCheeses.includes(value));
+ const isIndeterminate = someChecked && !allChecked;
+ const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth });
+
+ const handleChange = e => {
+ e.target.checked ? add(e.target.value) : remove(e.target.value);
+ };
+
+ const handleCheckAllChange = ({ checked }) => {
+ checked ? set(allCheeseOptions) : clear();
+ };
+
+ return (
+ <>
+
+
+ All the cheese
+
+
+
+
+
+
It is my first SelectableBox
+
Swiss
+
+
+
+ Cheddar
+
+
+ Pepperjack
+
+
+ >
+ );
+}
+```
+
+As ``Checkbox`` with ``ariaLabelledby``
+
+```jsx live
+() => {
+ const type = 'checkbox';
+ const allCheeseOptions = ['swiss', 'cheddar', 'pepperjack'];
+ const [checkedCheeses, { add, remove, set, clear }] = useCheckboxSetValues(['swiss']);
+
+ const handleChange = e => {
+ e.target.checked ? add(e.target.value) : remove(e.target.value);
+ };
+
+ const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth });
+
+ return (
+
+
+ Select your favorite cheese
+
+
+
+
+ Swiss
+
+
+
+
+ Cheddar
+
+
+
+
+ Pepperjack
+
+
+
+
+ );
+}
+```
diff --git a/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx b/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx
index d8523c161..a96cccab5 100644
--- a/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx
+++ b/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx
@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
-import { getInputType } from './getInputType';
-import { FormRadioSetContextProvider } from './FormRadioSetContext';
+import { getInputType } from './utils';
+// import { requiredWhenNot } from '../utils/propTypes';
const INPUT_TYPES = [
'radio',
@@ -26,7 +26,7 @@ const SelectableBoxSet = React.forwardRef(({
}, ref) => {
const inputType = getInputType('SelectableBoxSet', type);
- const selectableBoxElement = React.createElement(
+ return React.createElement(
inputType,
{
name,
@@ -45,22 +45,6 @@ const SelectableBoxSet = React.forwardRef(({
},
children,
);
-
- return type === 'radio' ? (
-
- {
- selectableBoxElement
- }
-
- ) : selectableBoxElement;
});
SelectableBoxSet.propTypes = {
diff --git a/src/editors/sharedComponents/SelectableBox/_variables.scss b/src/editors/sharedComponents/SelectableBox/_variables.scss
new file mode 100644
index 000000000..b7d4e114d
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/_variables.scss
@@ -0,0 +1,5 @@
+$selectable-box-padding: 1rem !default;
+$selectable-box-border-radius: .25rem !default;
+$selectable-box-space: .75rem !default;
+$min-cols-number: 1 !default;
+$max-cols-number: 12 !default;
diff --git a/src/editors/sharedComponents/SelectableBox/constants.js b/src/editors/sharedComponents/SelectableBox/constants.js
new file mode 100644
index 000000000..68abdda93
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/constants.js
@@ -0,0 +1,17 @@
+/* eslint-disable import/prefer-default-export */
+const FORM_CONTROL_SIZES = {
+ SMALL: 'sm',
+ LARGE: 'lg',
+};
+
+const FORM_TEXT_TYPES = {
+ DEFAULT: 'default',
+ VALID: 'valid',
+ INVALID: 'invalid',
+ WARNING: 'warning',
+ CRITERIA_EMPTY: 'criteria-empty',
+ CRITERIA_VALID: 'criteria-valid',
+ CRITERIA_INVALID: 'criteria-invalid',
+};
+
+export { FORM_CONTROL_SIZES, FORM_TEXT_TYPES };
diff --git a/src/editors/sharedComponents/SelectableBox/fieldUtils.js b/src/editors/sharedComponents/SelectableBox/fieldUtils.js
new file mode 100644
index 000000000..5c0d980a1
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/fieldUtils.js
@@ -0,0 +1,70 @@
+import classNames from 'classnames';
+import { useState, useEffect } from 'react';
+import newId from './newId';
+
+const omitUndefinedProperties = (obj = {}) => Object.entries(obj)
+ .reduce((acc, [key, value]) => {
+ if (value !== undefined) {
+ acc[key] = value;
+ }
+ return acc;
+ }, {});
+
+const callAllHandlers = (...handlers) => {
+ const unifiedEventHandler = (event) => {
+ handlers
+ .filter(handler => typeof handler === 'function')
+ .forEach(handler => handler(event));
+ };
+ return unifiedEventHandler;
+};
+
+const useHasValue = ({ defaultValue, value }) => {
+ const [hasUncontrolledValue, setHasUncontrolledValue] = useState(!!defaultValue || defaultValue === 0);
+ const hasValue = !!value || value === 0 || hasUncontrolledValue;
+ const handleInputEvent = (e) => setHasUncontrolledValue(e.target.value);
+ return [hasValue, handleInputEvent];
+};
+
+const useIdList = (uniqueIdPrefix, initialList) => {
+ const [idList, setIdList] = useState(initialList || []);
+ const addId = (idToAdd) => {
+ setIdList(oldIdList => [...oldIdList, idToAdd]);
+ return idToAdd;
+ };
+ const getNewId = () => {
+ const idToAdd = newId(`${uniqueIdPrefix}-`);
+ return addId(idToAdd);
+ };
+ const removeId = (idToRemove) => {
+ setIdList(oldIdList => oldIdList.filter(id => id !== idToRemove));
+ };
+
+ const useRegisteredId = (explicitlyRegisteredId) => {
+ const [registeredId, setRegisteredId] = useState(explicitlyRegisteredId);
+ useEffect(() => {
+ if (explicitlyRegisteredId) {
+ addId(explicitlyRegisteredId);
+ } else if (!registeredId) {
+ setRegisteredId(getNewId(uniqueIdPrefix));
+ }
+ return () => removeId(registeredId);
+ }, [registeredId, explicitlyRegisteredId]);
+ return registeredId;
+ };
+
+ return [idList, useRegisteredId];
+};
+
+const mergeAttributeValues = (...values) => {
+ const mergedValues = classNames(values);
+ return mergedValues || undefined;
+};
+
+export {
+ callAllHandlers,
+ useHasValue,
+ mergeAttributeValues,
+ useIdList,
+ omitUndefinedProperties,
+};
diff --git a/src/editors/sharedComponents/SelectableBox/index.jsx b/src/editors/sharedComponents/SelectableBox/index.jsx
index 3bebc6d77..40da72439 100644
--- a/src/editors/sharedComponents/SelectableBox/index.jsx
+++ b/src/editors/sharedComponents/SelectableBox/index.jsx
@@ -1,10 +1,10 @@
-import React, { useRef, useEffect, useContext } from 'react';
+import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import SelectableBoxSet from './SelectableBoxSet';
-import { getInputType } from './getInputType';
-import FormCheckboxSetContext from './FormCheckboxSetContext';
-import FormRadioSetContext from './FormRadioSetContext';
+import { useCheckboxSetContext } from './FormCheckboxSetContext';
+import { useRadioSetContext } from './FormRadioSetContext';
+import { getInputType } from './utils';
const INPUT_TYPES = [
'radio',
@@ -22,12 +22,11 @@ const SelectableBox = React.forwardRef(({
onFocus,
inputHidden,
className,
- showActiveBoxState,
...props
}, ref) => {
const inputType = getInputType('SelectableBox', type);
- const { value: radioValue } = useContext(FormRadioSetContext);
- const { value: checkboxValues = [] } = useContext(FormCheckboxSetContext);
+ const { value: radioValue } = useRadioSetContext();
+ const { value: checkboxValues = [] } = useCheckboxSetContext();
const isChecked = () => {
switch (type) {
@@ -64,7 +63,7 @@ const SelectableBox = React.forwardRef(({
onClick={() => inputRef.current.click()}
onFocus={onFocus}
className={classNames('pgn__selectable_box', className, {
- 'pgn__selectable_box-active': (!inputHidden && !showActiveBoxState) ? false : isChecked() || checked,
+ 'pgn__selectable_box-active': isChecked() || checked,
'pgn__selectable_box-invalid': isInvalid,
})}
tabIndex={0}
@@ -98,8 +97,6 @@ SelectableBox.propTypes = {
isInvalid: PropTypes.bool,
/** A class that is appended to the base element. */
className: PropTypes.string,
- /** Controls the visibility of the active state for the `SelectableBox`. */
- showActiveBoxState: PropTypes.bool,
};
SelectableBox.defaultProps = {
@@ -112,7 +109,6 @@ SelectableBox.defaultProps = {
isIndeterminate: false,
isInvalid: false,
className: undefined,
- showActiveBoxState: true,
};
SelectableBox.Set = SelectableBoxSet;
diff --git a/src/editors/sharedComponents/SelectableBox/index.scss b/src/editors/sharedComponents/SelectableBox/index.scss
new file mode 100644
index 000000000..1038286af
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/index.scss
@@ -0,0 +1,54 @@
+@import "variables";
+
+.pgn__selectable_box-set {
+ display: grid;
+ grid-auto-rows: 1fr;
+ grid-gap: $selectable-box-space;
+
+ @for $i from $min-cols-number through $max-cols-number {
+ &.pgn__selectable_box-set--#{$i} {
+ grid-template-columns: repeat(#{$i}, 1fr);
+ }
+ }
+
+ & > * + * {
+ margin: 0;
+ }
+}
+
+.pgn__selectable_box {
+ position: relative;
+ height: 100%;
+ padding: $selectable-box-padding;
+ box-shadow: $level-1-box-shadow;
+ border-radius: $selectable-box-border-radius;
+ text-align: start;
+ background: $white;
+
+ &:focus-visible {
+ outline: 1px solid $primary-700;
+ }
+
+ .pgn__form-radio,
+ .pgn__form-checkbox {
+ position: absolute;
+ top: $selectable-box-padding;
+ inset-inline-end: $selectable-box-padding;
+
+ input {
+ margin-inline-end: 0;
+ }
+ }
+
+ * {
+ pointer-events: none;
+ }
+}
+
+.pgn__selectable_box-active {
+ outline: 2px solid $primary-500;
+}
+
+.pgn__selectable_box-invalid {
+ outline: 2px solid $danger-300;
+}
diff --git a/src/editors/sharedComponents/SelectableBox/newId.js b/src/editors/sharedComponents/SelectableBox/newId.js
new file mode 100644
index 000000000..9055c9e31
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/newId.js
@@ -0,0 +1,8 @@
+let lastId = 0;
+
+const newId = (prefix = 'id') => {
+ lastId += 1;
+ return `${prefix}${lastId}`;
+};
+
+export default newId;
diff --git a/src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx b/src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx
new file mode 100644
index 000000000..226c5a9f8
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx
@@ -0,0 +1,124 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import renderer from 'react-test-renderer';
+
+import SelectableBox from '..';
+
+const checkboxType = 'checkbox';
+const checkboxText = 'SelectableCheckbox';
+
+const radioType = 'radio';
+const radioText = 'SelectableRadio';
+
+const SelectableCheckbox = (props) => {checkboxText} ;
+
+const SelectableRadio = (props) => {radioText} ;
+
+describe(' ', () => {
+ describe('correct rendering', () => {
+ it('renders without props', () => {
+ const tree = renderer.create((
+ SelectableBox
+ )).toJSON();
+ expect(tree).toMatchSnapshot();
+ });
+ it('correct render when type prop is changed', () => {
+ const { rerender } = render( );
+ const checkboxControl = screen.getByText(radioText);
+ expect(checkboxControl).toBeTruthy();
+ rerender( );
+ const radioControl = screen.getByText(radioText);
+ expect(radioControl).toBeTruthy();
+ });
+ it('renders with radio input type if neither checkbox nor radio is passed', () => {
+ // Mock the `console.error` is intentional because an invalid `type` prop
+ // with `wrongType` specified for `ForwardRef` expects one of the ['radio','flag'] parameters.
+ // eslint-disable-next-line no-console
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox).toBeTruthy();
+ consoleErrorSpy.mockRestore();
+ });
+ it('renders with checkbox input type', () => {
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox).toBeTruthy();
+ });
+ it('renders with radio input type', () => {
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox).toBeTruthy();
+ });
+ it('renders with correct children', () => {
+ render( );
+ const selectableBox = screen.getByText(radioText);
+ expect(selectableBox).toBeTruthy();
+ });
+ it('renders with correct class', () => {
+ const className = 'myClass';
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox.classList.contains(className)).toEqual(true);
+ });
+ it('renders as active when checked is passed', () => {
+ render( );
+ const selectableBox = screen.getByRole('button');
+ const inputElement = screen.getByRole('radio', { hidden: true });
+ expect(selectableBox.classList.contains('pgn__selectable_box-active')).toEqual(true);
+ expect(inputElement.checked).toEqual(true);
+ });
+ it('renders as invalid when isInvalid is passed', () => {
+ render( );
+ const selectableBox = screen.getByRole('button');
+ expect(selectableBox.classList.contains('pgn__selectable_box-invalid')).toEqual(true);
+ });
+ it('renders with on click event when onClick is passed', async () => {
+ const onClickSpy = jest.fn();
+ render( );
+ const selectableBox = screen.getByRole('button');
+ await userEvent.click(selectableBox);
+ expect(onClickSpy).toHaveBeenCalledTimes(1);
+ });
+ it('renders with on key press event when onClick is passed', async () => {
+ const onClickSpy = jest.fn();
+ render( );
+ const selectableBox = screen.getByRole('button');
+ selectableBox.focus();
+ await userEvent.keyboard('{enter}');
+ expect(onClickSpy).toHaveBeenCalledTimes(1);
+ });
+ it('renders with hidden input when inputHidden is passed', () => {
+ const { rerender } = render( );
+ const inputElement = screen.getByRole('checkbox', { hidden: true });
+ expect(inputElement.getAttribute('hidden')).toEqual('');
+ rerender( );
+ expect(inputElement.getAttribute('hidden')).toBeNull();
+ });
+ });
+ describe('correct interactions', () => {
+ it('correct checkbox state change when checked is changed', () => {
+ const { rerender } = render( );
+ const checkbox = screen.getByRole('button');
+ expect(checkbox.className).not.toContain('pgn__selectable_box-active');
+ rerender( );
+ expect(checkbox.className).toContain('pgn__selectable_box-active');
+ });
+ it('correct radio state change when checked is changed', () => {
+ const { rerender } = render( );
+ const radio = screen.getByRole('button');
+ expect(radio.className).toContain('pgn__selectable_box-active');
+ rerender( );
+ expect(radio.className).toContain('pgn__selectable_box-active');
+ });
+ it('ref is passed to onClick function', () => {
+ let inputRef;
+ const onClick = (ref) => { inputRef = ref; };
+ render( );
+ const radio = screen.getByRole('button');
+ userEvent.click(radio);
+ expect(inputRef).not.toBeFalsy();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx b/src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx
new file mode 100644
index 000000000..cf3db6f3c
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+import userEvent from '@testing-library/user-event';
+import SelectableBox from '..';
+
+const boxText = (text) => `SelectableBox${text}`;
+
+const checkboxType = 'checkbox';
+const checkboxText = (text) => `SelectableCheckbox${text}`;
+
+const radioType = 'radio';
+const radioText = (text) => `SelectableRadio${text}`;
+
+const ariaLabel = 'test-default-label';
+
+const SelectableBoxSet = (props) => (
+
+ {boxText(1)}
+ {boxText(2)}
+ {boxText(3)}
+
+);
+
+const SelectableCheckboxSet = (props) => (
+
+ {checkboxText(1)}
+ {checkboxText(2)}
+ {checkboxText(3)}
+
+);
+
+const SelectableRadioSet = (props) => (
+
+ {radioText(1)}
+ {radioText(2)}
+ {radioText(3)}
+
+);
+
+describe(' ', () => {
+ describe('correct rendering', () => {
+ it('renders without props', () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+ it('forwards props', () => {
+ render(( ));
+ expect(screen.getByTestId('test-radio-set-name')).toBeInTheDocument();
+ });
+ it('correct render when type prop is changed', () => {
+ const { rerender } = render( );
+ expect(screen.getByTestId('radio-set')).toBeInTheDocument();
+ rerender( );
+ expect(screen.getByTestId('radio-set')).toBeInTheDocument();
+ rerender( );
+ expect(screen.getByTestId('checkbox-set')).toBeInTheDocument();
+ });
+ it('renders with children', () => {
+ render(
+ {checkboxText(1)} ,
+ );
+ expect(screen.getByText(checkboxText(1))).toBeInTheDocument();
+ });
+ it('renders with on change event', async () => {
+ const onChangeSpy = jest.fn();
+ render( );
+ const checkbox = screen.getByRole('button', { name: checkboxText(1) });
+ await userEvent.click(checkbox);
+ expect(onChangeSpy).toHaveBeenCalledTimes(1);
+ });
+ it('renders with checkbox type', () => {
+ render( );
+ expect(screen.getByTestId('checkbox-set')).toBeInTheDocument();
+ });
+ it('renders with radio type if neither checkbox nor radio is passed', () => {
+ render( );
+ expect(screen.getByTestId('radio-set')).toBeInTheDocument();
+ });
+ it('renders with radio type', () => {
+ render( );
+ expect(screen.getByTestId('radio-set')).toBeInTheDocument();
+ });
+ it('renders with correct number of columns', () => {
+ const columns = 10;
+ render( );
+ const selectableBoxSet = screen.getByTestId('selectable-box-set');
+ expect(selectableBoxSet).toHaveClass(`pgn__selectable_box-set--${columns}`);
+ });
+ it('renders with an aria-label attribute', () => {
+ render(( ));
+ expect(screen.getByLabelText('test-radio-set-label')).toBeInTheDocument();
+ });
+ it('renders with an aria-labelledby attribute', () => {
+ render((
+ <>
+ Radio Set Label text
+
+ >
+ ));
+ expect(screen.getByLabelText('Radio Set Label text')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap
new file mode 100644
index 000000000..96676cabf
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap
@@ -0,0 +1,22 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` correct rendering renders without props 1`] = `
+
+
+ SelectableBox
+
+`;
diff --git a/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap
new file mode 100644
index 000000000..4c9a0ab8f
--- /dev/null
+++ b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap
@@ -0,0 +1,57 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` correct rendering renders without props 1`] = `
+
+`;
diff --git a/src/editors/sharedComponents/SelectableBox/getInputType.jsx b/src/editors/sharedComponents/SelectableBox/utils.js
similarity index 63%
rename from src/editors/sharedComponents/SelectableBox/getInputType.jsx
rename to src/editors/sharedComponents/SelectableBox/utils.js
index c0abf2daf..87efbab0c 100644
--- a/src/editors/sharedComponents/SelectableBox/getInputType.jsx
+++ b/src/editors/sharedComponents/SelectableBox/utils.js
@@ -1,6 +1,7 @@
-import {
- Form, CheckboxControl, RadioControl,
-} from '@openedx/paragon';
+import { CheckboxControl } from './FormCheckbox';
+import { RadioControl } from './FormRadio';
+import FormRadioSet from './FormRadioSet';
+import FormCheckboxSet from './FormCheckboxSet';
// eslint-disable-next-line import/prefer-default-export,consistent-return
export const getInputType = (component, type) => {
@@ -16,11 +17,11 @@ export const getInputType = (component, type) => {
} else if (component === 'SelectableBoxSet') {
switch (type) {
case 'radio':
- return Form.RadioSet;
+ return FormRadioSet;
case 'checkbox':
- return Form.CheckboxSet;
+ return FormCheckboxSet;
default:
- return Form.RadioSet;
+ return FormRadioSet;
}
}
};