Skip to content

Commit

Permalink
feat(SelectButtonGroup): add support for controlled behavior and impr…
Browse files Browse the repository at this point in the history
…ove API
  • Loading branch information
Carlo Bernardini committed Nov 3, 2021
1 parent 1425a74 commit 6a1f524
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 152 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
cursor: not-allowed;
}

&--isSelected.SelectButton--selectedContext_neutral {
&--isSelected.SelectButton--context_neutral {
border-color: var(--color-neutral-60);
background-color: var(--color-neutral-60);
color: var(--color-background);
Expand All @@ -45,7 +45,7 @@
}
}

&--isSelected.SelectButton--selectedContext {
&--isSelected.SelectButton--context {
@each $context in (brand, primary, accent, info, good, warning, bad) {
&_#{$context} {
border-color: var(--color-#{$context});
Expand Down
22 changes: 6 additions & 16 deletions src/components/SelectButtonGroup/SelectButton/SelectButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import styles from './SelectButton.scss';

// These props will be passed by the parent <SelectButtonGroup>
interface InternalProps<V> extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
/** if this button should be in a selected state */
isSelected?: boolean;
/** A function to be called if a button is pressed. */
onChange?: (value: V) => void;
/** If this component a child of a full block with container */
Expand All @@ -21,27 +19,18 @@ export interface Props<V> extends InternalProps<V> {
/** the value associated with this button */
value: V;
/** if this button should be in a selected when first rendered */
isInitiallySelected?: boolean;
isSelected?: boolean;
/** the color context to be applied in the selected state */
selectedContext?: Context;
context?: Context;
/** size of the button */
size?: Size;
}

const { block } = bem('SelectButton', styles);

export function SelectButton<V>(props: Props<V>) {
const {
children,
value,
onChange,
isEqualWidth,
isBlock,
selectedContext,
isInitiallySelected,
isSelected,
...rest
} = props;
const { children, value, onChange, isEqualWidth, isBlock, context, isSelected, ...rest } =
props;

const handleClick = () => {
onChange?.(value);
Expand Down Expand Up @@ -70,7 +59,8 @@ export function SelectButton<V>(props: Props<V>) {
SelectButton.displayName = 'SelectButton';

SelectButton.defaultProps = {
isInitiallySelected: false,
context: 'brand',
isSelected: false,
isEqualWidth: false,
size: 'normal',
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

exports[`SelectButton should render correctly 1`] = `
<SelectButton
context="brand"
isEqualWidth={false}
isInitiallySelected={false}
isSelected={false}
onChange={[MockFunction]}
size="normal"
value="button 1"
>
<div
className="SelectButton"
className="SelectButton SelectButton--context_brand"
onClick={[Function]}
onKeyPress={[Function]}
role="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,27 @@ import { Context, Size } from '../../../constants';
import { SelectButtonProps } from '../SelectButton';
import styles from './SelectButtonGroup.scss';

interface Props<V> extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
/** SelectButton components */
interface Props<V> extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'> {
/** SelectButton children */
children: React.ReactElement<SelectButtonProps<V>>[];
/**
* A function to be called selection is changed.
* For convenience it will always be called with an array. In case of single select this array will always be of max length 1
*/
onChange?: (selection: V[]) => void;
/** is this button part of a multiselect group - when yes will allow more then one option to be selected */
/** Function that is called when the selection is changed (controlled use) */
onChange?: (selection: V) => void;
/** should more than one option be allowed to be selected (uncontrolled use) */
isMultiselect?: boolean;
/** if this component should have at least one selected value (for now it effects only multiselect logic) */
/** if required, there should at least be one selected value (uncontrolled use) */
isRequired?: boolean;
/** should the component take up all the width available */
isBlock?: boolean;
/** Color context for selected buttons */
selectedContext?: Context;
context?: Context;
/** should children have equal width */
isEqualWidth?: boolean;
/** size of the button group */
size?: Size;
/** currently selected value(s) (controlled use) */
value?: V[];
/** currently selected value(s) (uncontrolled use) */
defaultValue?: V[];
}

const { block } = bem('SelectButtonGroup', styles);
Expand All @@ -35,54 +36,49 @@ export function SelectButtonGroup<V>(props: Props<V>) {
isRequired,
isEqualWidth,
isBlock,
selectedContext,
context,
onChange,
size,
value,
defaultValue,
...rest
} = props;

const initiallySelectedValues: V[] = [];
children.forEach((child) => {
const { value, isInitiallySelected } = child.props;
if (isInitiallySelected) {
initiallySelectedValues.push(value);
}
});

const [selectedValues, setSelectedValues] = React.useState(initiallySelectedValues);
const [selection, setSelection] = React.useState(defaultValue || []);

React.useEffect(() => {
onChange?.(selectedValues);
}, [onChange, selectedValues]);
const handleChangeInternally = (selectedValue: V) => {
const isCurrentlySelected = selection.includes(selectedValue);

const handleSelectionChangeForValue = (value: V) => {
if (!isMultiselect) {
if (isRequired || selectedValues[0] !== value) {
setSelectedValues([value]);
if (isRequired || !isCurrentlySelected) {
setSelection([selectedValue]);
} else {
setSelectedValues([]);
setSelection([]);
}
} else if (selectedValues.includes(value)) {
if (!(isRequired && selectedValues.length === 1)) {
setSelectedValues(selectedValues.filter((v) => v !== value));
} else if (isCurrentlySelected) {
if (!isRequired || selection.length > 1) {
setSelection([...selection.filter((v) => v !== selectedValue)]);
}
} else {
setSelectedValues([...selectedValues, value]);
setSelection([...selection, selectedValue]);
}
};

return (
<div {...rest} {...block(props)}>
{children.map((child) =>
React.cloneElement(child, {
{children.map((child) => {
const childProps = {
context: context || child.props.context,
isBlock,
isEqualWidth,
isSelected: selectedValues.includes(child.props.value),
onChange: handleSelectionChangeForValue,
selectedContext: child.props.selectedContext || selectedContext,
isSelected:
(value || selection).includes(child.props.value) || child.props.isSelected,
onChange: onChange || handleChangeInternally,
size,
})
)}
};

return React.cloneElement(child, childProps);
})}
</div>
);
}
Expand All @@ -94,7 +90,9 @@ SelectButtonGroup.defaultProps = {
isRequired: false,
isBlock: false,
isEqualWidth: false,
selectedContext: 'brand',
context: null,
size: 'normal',
onChange: null,
value: null,
defaultValue: null,
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ describe('SelectButtonGroup', () => {

beforeEach(() => {
wrapper = mount(
<SelectButtonGroup onChange={onChangeMock}>
<SelectButton value="button 1" isInitiallySelected key="1">
<SelectButtonGroup defaultValue={['1']}>
<SelectButton value="1" key="1">
Option 1
</SelectButton>
<SelectButton value="button 2" key="2">
<SelectButton value="2" key="2">
Option 2
</SelectButton>
<SelectButton value="button 3" key="3">
<SelectButton value="3" key="3">
Option 3
</SelectButton>
</SelectButtonGroup>
Expand Down Expand Up @@ -61,16 +61,6 @@ describe('SelectButtonGroup', () => {
expect(getButton(1).prop('isSelected')).toBeFalsy();
expect(getButton(2).prop('isSelected')).toBeFalsy();
});
it('should call onChange with correct parameters', () => {
getButton(1).simulate('click');
expect(onChangeMock).toHaveBeenLastCalledWith([getButton(1).prop('value')]);

getButton(0).simulate('click');
expect(onChangeMock).toHaveBeenLastCalledWith([getButton(0).prop('value')]);

getButton(0).simulate('click');
expect(onChangeMock).toHaveBeenLastCalledWith([]);
});
});
describe('Multi select mode', () => {
beforeEach(() => {
Expand Down Expand Up @@ -141,29 +131,25 @@ describe('SelectButtonGroup', () => {
expect(getButton(1).prop('isSelected')).toBeTruthy();
expect(getButton(2).prop('isSelected')).toBeFalsy();
});
it('should call onChange with correct parameters', () => {
getButton(1).simulate('click');
expect(onChangeMock).toHaveBeenLastCalledWith([
getButton(0).prop('value'),
getButton(1).prop('value'),
]);
});

getButton(2).simulate('click');
expect(onChangeMock).toHaveBeenLastCalledWith([
getButton(0).prop('value'),
getButton(1).prop('value'),
getButton(2).prop('value'),
]);
describe('Controlled behavior', () => {
beforeEach(() => {
wrapper.setProps({
value: ['1'],
onChange: onChangeMock,
defaultValue: undefined,
});
});

getButton(1).simulate('click');
expect(onChangeMock).toHaveBeenLastCalledWith([
getButton(0).prop('value'),
getButton(2).prop('value'),
]);
it('should render correctly', () => {
expect(toJson(wrapper)).toMatchSnapshot();
});

getButton(0).simulate('click');
getButton(2).simulate('click');
expect(onChangeMock).toHaveBeenLastCalledWith([]);
it('should call onChange with correct parameters', () => {
expect(getButton(0).prop('isSelected')).toBeTruthy();
getButton(1).simulate('click');
expect(onChangeMock).toHaveBeenLastCalledWith(getButton(1).prop('value'));
});
});
});

0 comments on commit 6a1f524

Please sign in to comment.