Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add radio option to ButtonGroup #20805

Merged
merged 38 commits into from
Apr 8, 2020
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5ccd36f
Add radio mode to ButtonGroup with aria attributes
ajlende Mar 11, 2020
c942d56
Destructure child props
ajlende Mar 11, 2020
9c85fc3
Add tab index for radio buttons
ajlende Mar 11, 2020
726ac0e
Add keyboard handlers
ajlende Mar 11, 2020
42e671c
Remove TODO comment
ajlende Mar 11, 2020
4a061ff
Add storybook example
ajlende Mar 11, 2020
4373a44
Add documentation example
ajlende Mar 11, 2020
adf8bab
Add StoryShot snapshot
ajlende Mar 11, 2020
cb97066
Mention ButtonGroup in RadioControl
ajlende Mar 11, 2020
0ca5022
Merge branch 'master' of github.com:WordPress/gutenberg into try/radi…
ajlende Mar 24, 2020
a39023b
Merge branch 'master' of github.com:WordPress/gutenberg into try/radi…
ajlende Mar 25, 2020
f798e69
Convert to using context instead of cloneElement
ajlende Mar 25, 2020
1af2d22
Merge refs so forwardRef is still usable with radio group
ajlende Mar 25, 2020
daca280
Refactor to move button props to button component
ajlende Mar 25, 2020
c946eab
Refactor for readability
ajlende Mar 25, 2020
3083dc6
Add comment about default value
ajlende Mar 25, 2020
4242001
Consolidate ref and className
ajlende Mar 26, 2020
86d1088
Fix useMemo return value
ajlende Mar 26, 2020
282ed66
Update snapshots
ajlende Mar 26, 2020
c0d3d51
Partially revert snapshots
ajlende Mar 26, 2020
bd0f097
Update prop order to match snapshots
ajlende Mar 26, 2020
287c9f0
Update comments for clarity
ajlende Mar 26, 2020
a0b7581
Merge branch 'master' of github.com:WordPress/gutenberg into try/radi…
ajlende Mar 26, 2020
213673e
Move changes to radio-group and radio
ajlende Mar 31, 2020
4b2851c
Merge branch 'master' into try/radio-button-group
ajlende Apr 1, 2020
d724209
Remove radio button group storybook snapshot
ajlende Apr 1, 2020
10d7e26
Update ButtonGroup extra props to override role
ajlende Apr 1, 2020
c01c3bf
Implement forwardRef for ButtonGroup
ajlende Apr 1, 2020
3129af7
Update snapshot with forwardRef(ButtonGroup)
ajlende Apr 1, 2020
37dc2b1
Update RadioControl to mention RadioGroup
ajlende Apr 1, 2020
c07e117
Replace Radio/RadioGroup with Reakit version
ajlende Apr 1, 2020
3def111
Add storybook stories for Radio/RadioGroup
ajlende Apr 1, 2020
4f66910
Update Radio/RadioGroup READMEs
ajlende Apr 1, 2020
c611871
Update docs manifest
ajlende Apr 1, 2020
7a417ac
Add __experimental prefix for Radio/RadioGroup
ajlende Apr 4, 2020
538538f
Pass through disabled state to children
ajlende Apr 4, 2020
da54def
Remove Radio ids from stories
ajlende Apr 4, 2020
f2dad34
Update snapshots
ajlende Apr 4, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/components/src/button-group/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Button groups that cannot be selected can either be given a disabled state, or b

### Usage

**As a simple group**

```jsx
import { Button, ButtonGroup } from '@wordpress/components';

Expand All @@ -57,6 +59,25 @@ const MyButtonGroup = () => (
);
```

**As a radio group**

```jsx
import { Button, ButtonGroup } from '@wordpress/components';
import { useState } from '@wordpress/element';

const MyRadioButtonGroup = () => {
const [ checked, setChecked ] = useState( 'medium' );
return (
<ButtonGroup mode="radio" onChange={ setChecked } checked={ checked }>
<Button value="small">Small</Button>
<Button value="medium">Medium</Button>
<Button value="large">Large</Button>
</ButtonGroup>
);
};
```

## Related components

- For individual buttons, use a `Button` component.
- For a traditional radio group, use a `RadioControl` component.
66 changes: 64 additions & 2 deletions packages/components/src/button-group/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,72 @@
*/
import classnames from 'classnames';

function ButtonGroup( { className, ...props } ) {
/**
* WordPress dependencies
*/
import { Children, useRef, createContext, useMemo } from '@wordpress/element';

// Default values for when a button isn't a child of a group
export const ButtonGroupContext = createContext( {
mode: null,
buttons: {},
} );

function ButtonGroup( {
mode,
checked,
onChange,
className,
children,
...props
} ) {
const classes = classnames( 'components-button-group', className );
const role = mode === 'radio' ? 'radiogroup' : 'group';
const childRefs = useRef( [] );

const buttons = useMemo( () => {
const buttonsContext = {};
if ( mode === 'radio' ) {
const childrenArray = Children.toArray( children );
childrenArray.forEach( ( child, index ) => {
buttonsContext[ child.props.value ] = {
isChecked: checked === child.props.value,
isFirst: ! checked && index === 0,
onPrev: () => {
const prevIndex =
( index - 1 + childrenArray.length ) %
childrenArray.length;
childRefs.current[ prevIndex ].focus();
onChange( childrenArray[ prevIndex ].props.value );
},
onNext: () => {
const nextIndex = ( index + 1 ) % childrenArray.length;
childRefs.current[ nextIndex ].focus();
onChange( childrenArray[ nextIndex ].props.value );
},
onSelect: () => {
onChange( child.props.value );
},
refCallback: ( ref ) => {
if ( ref === null ) {
delete childRefs.current[ index ];
} else {
childRefs.current[ index ] = ref;
}
},
};
} );
}
return buttonsContext;
}, [ children, mode, onChange, checked, childRefs ] );

return <div { ...props } className={ classes } role="group" />;
return (
<div className={ classes } role={ role } { ...props }>
<ButtonGroupContext.Provider value={ { mode, buttons } }>
{ children }
</ButtonGroupContext.Provider>
</div>
);
}

export default ButtonGroup;
20 changes: 20 additions & 0 deletions packages/components/src/button-group/stories/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';

/**
* Internal dependencies
*/
Expand All @@ -19,3 +24,18 @@ export const _default = () => {
</ButtonGroup>
);
};

const ButtonGroupWithState = () => {
const [ checked, setChecked ] = useState( 'medium' );
return (
<ButtonGroup mode="radio" onChange={ setChecked } checked={ checked }>
<Button value="small">Small</Button>
<Button value="medium">Medium</Button>
<Button value="large">Large</Button>
</ButtonGroup>
);
};

export const radioButtonGroup = () => {
return <ButtonGroupWithState />;
};
70 changes: 67 additions & 3 deletions packages/components/src/button/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { isArray } from 'lodash';
* WordPress dependencies
*/
import deprecated from '@wordpress/deprecated';
import { forwardRef } from '@wordpress/element';
import { forwardRef, useContext } from '@wordpress/element';

/**
* Internal dependencies
*/
import { ButtonGroupContext } from '../button-group';
import Tooltip from '../tooltip';
import Icon from '../icon';

Expand Down Expand Up @@ -41,6 +42,9 @@ export function Button( props, ref ) {
shortcut,
label,
children,
value,
onKeyDown,
onClick,
__experimentalIsFocusable: isFocusable,
...additionalProps
} = props;
Expand Down Expand Up @@ -76,19 +80,75 @@ export function Button( props, ref ) {
'aria-pressed': isPressed,
};

const disabledEventProps = {};

if ( disabled && isFocusable ) {
// In this case, the button will be disabled, but still focusable and
// perceivable by screen reader users.
tagProps[ 'aria-disabled' ] = true;

for ( const disabledEvent of disabledEventsOnDisabledButton ) {
additionalProps[ disabledEvent ] = ( event ) => {
disabledEventProps[ disabledEvent ] = ( event ) => {
event.stopPropagation();
event.preventDefault();
};
}
}

const groupContext = useContext( ButtonGroupContext );
const buttonContext = groupContext.buttons[ value ];
const groupProps = {};

if ( groupContext.mode === 'radio' && buttonContext ) {
const {
isChecked,
isFirst,
onPrev,
onNext,
onSelect,
refCallback,
} = buttonContext;

Object.assign( groupProps, {
role: groupContext.mode,
'aria-checked': isChecked,
tabIndex: isChecked || isFirst ? 0 : -1,
// Pass through events and also handle the keyboard controls
onKeyDown( e ) {
if ( typeof onKeyDown === 'function' ) onKeyDown( e );
if ( e.key === 'ArrowUp' || e.key === 'ArrowLeft' ) {
e.preventDefault();
onPrev();
}
if ( e.key === 'ArrowDown' || e.key === 'ArrowRight' ) {
e.preventDefault();
onNext();
}
},
// May get overridden when disabled && isFocusable
onClick( e ) {
if ( typeof onClick === 'function' ) onClick( e );
onSelect();
},
// Grab a ref for handling onPrev and onNext in the group, but also
// pass through the ref from forwardRef
ref: ( current ) => {
refCallback( current );

if ( typeof ref === 'function' ) {
ref( current );
} else if ( ref ) {
ref.current = current;
}
},
// Automatically handle the styling for the selected button
className: classnames( classes, {
'is-secondary': ! isChecked,
'is-primary': isChecked,
} ),
} );
}

// Should show the tooltip if...
const shouldShowTooltip =
! trulyDisabled &&
Expand All @@ -106,11 +166,15 @@ export function Button( props, ref ) {

const element = (
<Tag
ref={ ref }
{ ...tagProps }
{ ...additionalProps }
className={ classes }
aria-label={ additionalProps[ 'aria-label' ] || label }
ref={ ref }
onKeyDown={ onKeyDown }
onClick={ onClick }
{ ...groupProps } // Overrides className, onKeyDown, and onClick
{ ...disabledEventProps } // May override onMouseDown and/or onClick
>
{ icon && <Icon icon={ icon } size={ iconSize } /> }
{ children }
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/radio-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,4 @@ A function that receives the value of the new option that is being selected as i

* To select one or more items from a set, use the `CheckboxControl` component.
* To toggle a single setting on or off, use the `ToggleControl` component.
* To format as a button group, use the `ButtonGroup` component with `role="radio"`.
41 changes: 41 additions & 0 deletions storybook/test/__snapshots__/index.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,47 @@ exports[`Storyshots Components/ButtonGroup Default 1`] = `
</div>
`;

exports[`Storyshots Components/ButtonGroup Radio Button Group 1`] = `
<div
className="components-button-group"
role="radiogroup"
>
<button
aria-checked={false}
className="components-button is-secondary"
onClick={[Function]}
onKeyDown={[Function]}
role="radio"
tabIndex={-1}
type="button"
>
Small
</button>
<button
aria-checked={true}
className="components-button is-primary"
onClick={[Function]}
onKeyDown={[Function]}
role="radio"
tabIndex={0}
type="button"
>
Medium
</button>
<button
aria-checked={false}
className="components-button is-secondary"
onClick={[Function]}
onKeyDown={[Function]}
role="radio"
tabIndex={-1}
type="button"
>
Large
</button>
</div>
`;

exports[`Storyshots Components/Card Default 1`] = `
.emotion-6 {
background: #fff;
Expand Down