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
This commit is contained in:
Jesper Hodge
2024-03-11 12:59:05 -04:00
committed by GitHub
parent 5069cf8638
commit be00028c4a
26 changed files with 1468 additions and 83 deletions

View File

@@ -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';

View File

@@ -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"
>

View File

@@ -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 (
<input
type="checkbox"
{...checkboxProps}
ref={resolvedRef}
/>
);
},
);
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 (
<FormGroupContextProvider
controlId={props.id}
isInvalid={isInvalid}
isValid={isValid}
>
<div
className={classNames('pgn__form-checkbox', className, {
'pgn__form-control-valid': isValid,
'pgn__form-control-invalid': isInvalid,
'pgn__form-control-disabled': props.disabled,
'pgn__form-control-label-left': !!floatLabelLeft,
})}
{...groupProps}
>
{control}
<div>
<FormLabel className={labelClassName}>
{children}
</FormLabel>
{description && (
<FormControlFeedback hasIcon={false}>
{description}
</FormControlFeedback>
)}
</div>
</div>
</FormGroupContextProvider>
);
});
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;

View File

@@ -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 (
<FormCheckboxSetContextProvider
name={name}
value={value}
defaultValue={defaultValue}
onFocus={onFocus}
onBlur={onBlur}
onChange={onChange}
>
<FormControlSet role="group" isInline={isInline} {...controlProps}>
{children}
</FormControlSet>
</FormCheckboxSetContextProvider>
);
};
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;

View File

@@ -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;

View File

@@ -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 (
<FormText
{...descriptorProps}
className={className}
type={textType}
>
{children}
</FormText>
);
};
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;

View File

@@ -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;

View File

@@ -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 <Form.Label>
// 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 (
<FormGroupContext.Provider value={contextValue}>
{children}
</FormGroupContext.Provider>
);
};
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,
};

View File

@@ -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;

View File

@@ -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 (
<input {...{ ...radioProps, onChange }} type="radio" ref={ref} />
);
});
RadioControl.propTypes = {
className: PropTypes.string,
};
RadioControl.defaultProps = {
className: undefined,
};
const FormRadio = React.forwardRef(({
children,
className,
controlClassName,
labelClassName,
description,
isInvalid,
isValid,
...props
}, ref) => (
<FormGroupContextProvider
controlId={props.id}
isInvalid={isInvalid}
isValid={isValid}
>
<div
className={classNames('pgn__form-radio', className, {
'pgn__form-control-valid': isValid,
'pgn__form-control-invalid': isInvalid,
'pgn__form-control-disabled': props.disabled,
})}
>
<RadioControl ref={ref} className={controlClassName} {...props} />
<div>
<FormLabel className={labelClassName}>
{children}
</FormLabel>
{description && (
<FormControlFeedback hasIcon={false}>
{description}
</FormControlFeedback>
)}
</div>
</div>
</FormGroupContextProvider>
));
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;

View File

@@ -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 (
<FormRadioSetContextProvider
name={name}
value={value}
defaultValue={defaultValue}
onFocus={onFocus}
onBlur={onBlur}
onChange={onChange}
>
<FormControlSet role="radiogroup" isInline={isInline} {...controlProps}>
{children}
</FormControlSet>
</FormRadioSetContextProvider>
);
};
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;

View File

@@ -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,
});

View File

@@ -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 <Icon src={typeIcon} />;
// }
// 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 (
<div {...props} className={className}>
{/* {hasIcon && <FormTextIcon customIcon={icon} type={type} />} */}
<div>
{children}
</div>
</div>
);
};
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,
};

View File

@@ -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 (
<div className="bg-light-200 p-3">
<SelectableBox.Set
value={checkedCheeses}
type={type}
onChange={handleChange}
name="cheeses"
columns={isExtraSmall ? 1 : 2}
ariaLabel="cheese selection"
>
<SelectableBox value="swiss" type={type} aria-label="swiss checkbox">
<div>
<h3>It is my first SelectableBox</h3>
<p>Swiss</p>
</div>
</SelectableBox>
<SelectableBox value="cheddar" inputHidden={false} type={type} aria-label="cheddar checkbox">
Cheddar
</SelectableBox>
<SelectableBox
value="pepperjack"
inputHidden={false}
type={type}
isInvalid={isInvalid()}
aria-label="pepperjack checkbox"
>
<h3>Pepperjack</h3>
</SelectableBox>
</SelectableBox.Set>
</div>
);
}
```
## 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 (
<SelectableBox.Set
type={type}
value={value}
onChange={handleChange}
name="colors"
columns={isExtraSmall ? 1 : 3}
ariaLabel="color selection"
>
<SelectableBox value="red" type={type} aria-label="red checkbox">
<div>
<h3>It is Red color</h3>
<p>Select me</p>
</div>
</SelectableBox>
<SelectableBox value="green" inputHidden={false} type={type} aria-label="green-checkbox">
<h3>Green</h3>
<p>Leaves and grass</p>
</SelectableBox>
<SelectableBox value="blue" inputHidden={false} type={type} aria-label="blue checkbox">
<h3>Blue</h3>
<p>The sky</p>
</SelectableBox>
</SelectableBox.Set>
);
}
```
## 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 (
<>
<div className="mb-3">
<SelectableBox
checked={allChecked}
isIndeterminate={isIndeterminate}
onClick={handleCheckAllChange}
inputHidden={false}
type={type}
aria-label="all options checkbox"
>
All the cheese
</SelectableBox>
</div>
<SelectableBox.Set
value={checkedCheeses}
type={type}
onChange={handleChange}
name="cheeses"
columns={isExtraSmall ? 1 : 3}
ariaLabel="cheese selection"
>
<SelectableBox value="swiss" type={type} aria-label="swiss checkbox">
<div>
<h3>It is my first SelectableBox</h3>
<p>Swiss</p>
</div>
</SelectableBox>
<SelectableBox value="cheddar" inputHidden={false} type={type} aria-label="cheddar checkbox">
Cheddar
</SelectableBox>
<SelectableBox value="pepperjack" inputHidden={false} type={type} aria-label="pepperjack checkbox">
<h3>Pepperjack</h3>
</SelectableBox>
</SelectableBox.Set>
</>
);
}
```
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 (
<div className="bg-light-200 p-3">
<h3 id="cheese selection" className="mb-4">
Select your favorite cheese
</h3>
<SelectableBox.Set
value={checkedCheeses}
type={type}
onChange={handleChange}
name="cheeses"
columns={isExtraSmall ? 1 : 3}
ariaLabelledby="cheese selection"
>
<SelectableBox value="swiss" inputHidden={false} type={type} aria-label="swiss checkbox">
<h3>
Swiss
</h3>
</SelectableBox>
<SelectableBox value="cheddar" inputHidden={false} type={type} aria-label="cheddar checkbox">
<h3>
Cheddar
</h3>
</SelectableBox>
<SelectableBox value="pepperjack" inputHidden={false} type={type} aria-label="pepperjack checkbox">
<h3>
Pepperjack
</h3>
</SelectableBox>
</SelectableBox.Set>
</div>
);
}
```

View File

@@ -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' ? (
<FormRadioSetContextProvider
{...{
children,
name,
onChange,
value,
defaultValue,
}}
>
{
selectableBoxElement
}
</FormRadioSetContextProvider>
) : selectableBoxElement;
});
SelectableBoxSet.propTypes = {

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
let lastId = 0;
const newId = (prefix = 'id') => {
lastId += 1;
return `${prefix}${lastId}`;
};
export default newId;

View File

@@ -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) => <SelectableBox type={checkboxType} {...props}>{checkboxText}</SelectableBox>;
const SelectableRadio = (props) => <SelectableBox type={radioType} {...props}>{radioText}</SelectableBox>;
describe('<SelectableBox />', () => {
describe('correct rendering', () => {
it('renders without props', () => {
const tree = renderer.create((
<SelectableBox>SelectableBox</SelectableBox>
)).toJSON();
expect(tree).toMatchSnapshot();
});
it('correct render when type prop is changed', () => {
const { rerender } = render(<SelectableRadio type="checkbox" />);
const checkboxControl = screen.getByText(radioText);
expect(checkboxControl).toBeTruthy();
rerender(<SelectableRadio type="radio" />);
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(<SelectableRadio type="wrongType" />);
const selectableBox = screen.getByRole('button');
expect(selectableBox).toBeTruthy();
consoleErrorSpy.mockRestore();
});
it('renders with checkbox input type', () => {
render(<SelectableCheckbox />);
const selectableBox = screen.getByRole('button');
expect(selectableBox).toBeTruthy();
});
it('renders with radio input type', () => {
render(<SelectableCheckbox />);
const selectableBox = screen.getByRole('button');
expect(selectableBox).toBeTruthy();
});
it('renders with correct children', () => {
render(<SelectableRadio />);
const selectableBox = screen.getByText(radioText);
expect(selectableBox).toBeTruthy();
});
it('renders with correct class', () => {
const className = 'myClass';
render(<SelectableRadio className={className} />);
const selectableBox = screen.getByRole('button');
expect(selectableBox.classList.contains(className)).toEqual(true);
});
it('renders as active when checked is passed', () => {
render(<SelectableRadio checked />);
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(<SelectableRadio isInvalid />);
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(<SelectableCheckbox onClick={onClickSpy} />);
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(<SelectableCheckbox onClick={onClickSpy} />);
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(<SelectableCheckbox inputHidden />);
const inputElement = screen.getByRole('checkbox', { hidden: true });
expect(inputElement.getAttribute('hidden')).toEqual('');
rerender(<SelectableCheckbox inputHidden={false} />);
expect(inputElement.getAttribute('hidden')).toBeNull();
});
});
describe('correct interactions', () => {
it('correct checkbox state change when checked is changed', () => {
const { rerender } = render(<SelectableCheckbox checked={false} />);
const checkbox = screen.getByRole('button');
expect(checkbox.className).not.toContain('pgn__selectable_box-active');
rerender(<SelectableCheckbox checked />);
expect(checkbox.className).toContain('pgn__selectable_box-active');
});
it('correct radio state change when checked is changed', () => {
const { rerender } = render(<SelectableRadio checked={false} />);
const radio = screen.getByRole('button');
expect(radio.className).toContain('pgn__selectable_box-active');
rerender(<SelectableRadio checked />);
expect(radio.className).toContain('pgn__selectable_box-active');
});
it('ref is passed to onClick function', () => {
let inputRef;
const onClick = (ref) => { inputRef = ref; };
render(<SelectableRadio onClick={onClick} />);
const radio = screen.getByRole('button');
userEvent.click(radio);
expect(inputRef).not.toBeFalsy();
});
});
});

View File

@@ -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) => (
<SelectableBox.Set name="box" ariaLabel={ariaLabel} {...props}>
<SelectableBox value={1}>{boxText(1)}</SelectableBox>
<SelectableBox value={2}>{boxText(2)}</SelectableBox>
<SelectableBox value={3}>{boxText(3)}</SelectableBox>
</SelectableBox.Set>
);
const SelectableCheckboxSet = (props) => (
<SelectableBox.Set name={radioType} type={checkboxType} ariaLabel={ariaLabel} {...props}>
<SelectableBox value={1} type={checkboxType}>{checkboxText(1)}</SelectableBox>
<SelectableBox value={2} type={checkboxType}>{checkboxText(2)}</SelectableBox>
<SelectableBox value={3} type={checkboxType}>{checkboxText(3)}</SelectableBox>
</SelectableBox.Set>
);
const SelectableRadioSet = (props) => (
<SelectableBox.Set name={radioType} type={radioType} ariaLabel={ariaLabel} {...props}>
<SelectableBox value={1} type={radioType}>{radioText(1)}</SelectableBox>
<SelectableBox value={2} type={radioType}>{radioText(2)}</SelectableBox>
<SelectableBox value={3} type={radioType}>{radioText(3)}</SelectableBox>
</SelectableBox.Set>
);
describe('<SelectableBox.Set />', () => {
describe('correct rendering', () => {
it('renders without props', () => {
const { container } = render(<SelectableRadioSet name="testName" />);
expect(container).toMatchSnapshot();
});
it('forwards props', () => {
render((<SelectableRadioSet name="testName" data-testid="test-radio-set-name" />));
expect(screen.getByTestId('test-radio-set-name')).toBeInTheDocument();
});
it('correct render when type prop is changed', () => {
const { rerender } = render(<SelectableRadioSet name="set" data-testid="radio-set" />);
expect(screen.getByTestId('radio-set')).toBeInTheDocument();
rerender(<SelectableRadioSet name="set" type="radio" data-testid="radio-set" />);
expect(screen.getByTestId('radio-set')).toBeInTheDocument();
rerender(<SelectableRadioSet name="set" type="checkbox" data-testid="checkbox-set" />);
expect(screen.getByTestId('checkbox-set')).toBeInTheDocument();
});
it('renders with children', () => {
render(
<SelectableCheckboxSet name="testName">{checkboxText(1)}</SelectableCheckboxSet>,
);
expect(screen.getByText(checkboxText(1))).toBeInTheDocument();
});
it('renders with on change event', async () => {
const onChangeSpy = jest.fn();
render(<SelectableCheckboxSet onChange={onChangeSpy} />);
const checkbox = screen.getByRole('button', { name: checkboxText(1) });
await userEvent.click(checkbox);
expect(onChangeSpy).toHaveBeenCalledTimes(1);
});
it('renders with checkbox type', () => {
render(<SelectableCheckboxSet data-testid="checkbox-set" />);
expect(screen.getByTestId('checkbox-set')).toBeInTheDocument();
});
it('renders with radio type if neither checkbox nor radio is passed', () => {
render(<SelectableBoxSet data-testid="radio-set" />);
expect(screen.getByTestId('radio-set')).toBeInTheDocument();
});
it('renders with radio type', () => {
render(<SelectableRadioSet type={radioType} data-testid="radio-set" />);
expect(screen.getByTestId('radio-set')).toBeInTheDocument();
});
it('renders with correct number of columns', () => {
const columns = 10;
render(<SelectableRadioSet columns={columns} data-testid="selectable-box-set" />);
const selectableBoxSet = screen.getByTestId('selectable-box-set');
expect(selectableBoxSet).toHaveClass(`pgn__selectable_box-set--${columns}`);
});
it('renders with an aria-label attribute', () => {
render((<SelectableRadioSet name="testName" ariaLabel="test-radio-set-label" />));
expect(screen.getByLabelText('test-radio-set-label')).toBeInTheDocument();
});
it('renders with an aria-labelledby attribute', () => {
render((
<>
<h2 id="test-radio-set-label">Radio Set Label text</h2>
<SelectableRadioSet
name="testName"
ariaLabelledby="test-radio-set-label"
/>
</>
));
expect(screen.getByLabelText('Radio Set Label text')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SelectableBox /> correct rendering renders without props 1`] = `
<div
className="pgn__selectable_box pgn__selectable_box-active"
onClick={[Function]}
onFocus={[Function]}
onKeyPress={[Function]}
role="button"
tabIndex={0}
>
<input
checked={false}
className="pgn__form-radio-input"
hidden={true}
onChange={[Function]}
tabIndex={-1}
type="radio"
/>
SelectableBox
</div>
`;

View File

@@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SelectableBox.Set /> correct rendering renders without props 1`] = `
<div>
<div
aria-label="test-default-label"
class="pgn__selectable_box-set pgn__selectable_box-set--2 pgn__form-control-set"
role="radiogroup"
>
<div
class="pgn__selectable_box"
role="button"
tabindex="0"
>
<input
class="pgn__form-radio-input"
hidden=""
name="testName"
tabindex="-1"
type="radio"
value="1"
/>
SelectableRadio1
</div>
<div
class="pgn__selectable_box"
role="button"
tabindex="0"
>
<input
class="pgn__form-radio-input"
hidden=""
name="testName"
tabindex="-1"
type="radio"
value="2"
/>
SelectableRadio2
</div>
<div
class="pgn__selectable_box"
role="button"
tabindex="0"
>
<input
class="pgn__form-radio-input"
hidden=""
name="testName"
tabindex="-1"
type="radio"
value="3"
/>
SelectableRadio3
</div>
</div>
</div>
`;

View File

@@ -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;
}
}
};