Skip to content

Commit

Permalink
Make SegmentedControl uncontrolled by default (#2189)
Browse files Browse the repository at this point in the history
* makes SegmentedControl uncontrolled by default

* corrects onChange in SegmentedControl prop table

* adds changeset

* fixes broken test

* adds selected and defaultSelected props, adds controlled SegmentedControl example to docs

* fixes docs 'Controlled' example
  • Loading branch information
mperrotti authored Aug 11, 2022
1 parent 8f5883d commit 3571658
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 48 deletions.
5 changes: 5 additions & 0 deletions .changeset/cuddly-experts-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Makes SegmentedControl uncontrolled by default.
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,45 @@ status: Draft
description: Use a segmented control to let users select an option from a short list and immediately apply the selection
---

<Note variant="warning">Not implemented yet</Note>

## Examples

### Simple
### Uncontrolled (default)

```jsx live drafts
<SegmentedControl aria-label="File view">
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
```

### Controlled

```javascript noinline live drafts
const Controlled = () => {
const [selectedIndex, setSelectedIndex] = React.useState(0)

const handleSegmentChange = selectedIndex => {
setSelectedIndex(selectedIndex)
}

return (
<SegmentedControl aria-label="File view" onChange={handleSegmentChange}>
<SegmentedControl.Button selected={selectedIndex === 0}>Preview</SegmentedControl.Button>
<SegmentedControl.Button selected={selectedIndex === 1}>Raw</SegmentedControl.Button>
<SegmentedControl.Button selected={selectedIndex === 2}>Blame</SegmentedControl.Button>
</SegmentedControl>
)
}

render(Controlled)
```

### With icons and labels

```jsx live drafts
<SegmentedControl aria-label="File view">
<SegmentedControl.Button selected leadingIcon={EyeIcon}>
<SegmentedControl.Button defaultSelected leadingIcon={EyeIcon}>
Preview
</SegmentedControl.Button>
<SegmentedControl.Button leadingIcon={FileCodeIcon}>Raw</SegmentedControl.Button>
Expand All @@ -34,7 +54,7 @@ description: Use a segmented control to let users select an option from a short

```jsx live drafts
<SegmentedControl aria-label="File view">
<SegmentedControl.IconButton selected icon={EyeIcon} aria-label="Preview" />
<SegmentedControl.IconButton defaultSelected icon={EyeIcon} aria-label="Preview" />
<SegmentedControl.IconButton icon={FileCodeIcon} aria-label="Raw" />
<SegmentedControl.IconButton icon={PeopleIcon} aria-label="Blame" />
</SegmentedControl>
Expand All @@ -44,7 +64,7 @@ description: Use a segmented control to let users select an option from a short

```jsx live drafts
<SegmentedControl aria-label="File view" variant={{narrow: 'hideLabels', regular: 'default'}}>
<SegmentedControl.Button selected leadingIcon={EyeIcon}>
<SegmentedControl.Button defaultSelected leadingIcon={EyeIcon}>
Preview
</SegmentedControl.Button>
<SegmentedControl.Button leadingIcon={FileCodeIcon}>Raw</SegmentedControl.Button>
Expand All @@ -56,7 +76,7 @@ description: Use a segmented control to let users select an option from a short

```jsx live drafts
<SegmentedControl aria-label="File view" variant={{narrow: 'dropdown', regular: 'default'}}>
<SegmentedControl.Button selected leadingIcon={EyeIcon}>
<SegmentedControl.Button defaultSelected leadingIcon={EyeIcon}>
Preview
</SegmentedControl.Button>
<SegmentedControl.Button leadingIcon={FileCodeIcon}>Raw</SegmentedControl.Button>
Expand All @@ -68,7 +88,7 @@ description: Use a segmented control to let users select an option from a short

```jsx live drafts
<SegmentedControl fullWidth aria-label="File view">
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
Expand All @@ -78,7 +98,7 @@ description: Use a segmented control to let users select an option from a short

```jsx live drafts
<SegmentedControl loading aria-label="File view">
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
Expand All @@ -97,7 +117,7 @@ description: Use a segmented control to let users select an option from a short
</Text>
</Box>
<SegmentedControl aria-labelledby="scLabel-vert" aria-describedby="scCaption-vert">
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
Expand All @@ -110,7 +130,7 @@ description: Use a segmented control to let users select an option from a short
<FormControl>
<FormControl.Label id="scLabel-horiz">File view</FormControl.Label>
<SegmentedControl aria-labelledby="scLabel-horiz" aria-describedby="scCaption-horiz">
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
<SegmentedControl.Button defaultSelected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
Expand All @@ -122,23 +142,8 @@ description: Use a segmented control to let users select an option from a short

```jsx live drafts
<SegmentedControl aria-label="File view">
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
<SegmentedControl.Button selected>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
```

### With a selection change handler

```jsx live drafts
<SegmentedControl
aria-label="File view"
onChange={selectedIndex => {
alert(`Segment ${selectedIndex}`)
}}
>
<SegmentedControl.Button>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button defaultSelected>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
</SegmentedControl>
```
Expand Down Expand Up @@ -166,7 +171,6 @@ description: Use a segmented control to let users select an option from a short
name="onChange"
type="(selectedIndex?: number) => void"
description="The handler that gets called when a segment is selected"
required
/>
<PropsTableRow
name="variant"
Expand All @@ -187,7 +191,16 @@ description: Use a segmented control to let users select an option from a short

<PropsTable>
<PropsTableRow name="leadingIcon" type="Component" description="The leading icon comes before item label" />
<PropsTableRow name="selected" type="boolean" description="Whether the segment is selected" />
<PropsTableRow
name="selected"
type="boolean"
description="Whether the segment is selected. This is used for controlled SegmentedControls, and needs to be updated using the onChange handler on SegmentedControl."
/>
<PropsTableRow
name="defaultSelected"
type="boolean"
description="Whether the segment is selected. This is used for uncontrolled SegmentedControls to pick one SegmentedControlButton that is selected on the initial render."
/>
<PropsTableSxRow />
<PropsTableRefRow refType="HTMLButtonElement" />
</PropsTable>
Expand All @@ -202,7 +215,16 @@ description: Use a segmented control to let users select an option from a short
description="The icon that represents the segmented control item"
required
/>
<PropsTableRow name="selected" type="boolean" description="Whether the segment is selected" />
<PropsTableRow
name="selected"
type="boolean"
description="Whether the segment is selected. This is used for controlled SegmentedControls, and needs to be updated using the onChange handler on SegmentedControl."
/>
<PropsTableRow
name="defaultSelected"
type="boolean"
description="Whether the segment is selected. This is used for uncontrolled SegmentedControls to pick one SegmentedControlButton that is selected on the initial render."
/>
<PropsTableSxRow />
<PropsTableRefRow refType="HTMLButtonElement" />
</PropsTable>
Expand Down
19 changes: 19 additions & 0 deletions src/SegmentedControl/SegmentedControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,25 @@ describe('SegmentedControl', () => {
expect(handleChange).toHaveBeenCalledWith(1)
})

it('changes selection to the clicked segment even without onChange being passed', async () => {
const user = userEvent.setup()
const {getByText} = render(
<SegmentedControl aria-label="File view">
{segmentData.map(({label}) => (
<SegmentedControl.Button key={label}>{label}</SegmentedControl.Button>
))}
</SegmentedControl>
)

const buttonToClick = getByText('Raw').closest('button')

expect(buttonToClick?.getAttribute('aria-current')).toBe('false')
if (buttonToClick) {
await user.click(buttonToClick)
}
expect(buttonToClick?.getAttribute('aria-current')).toBe('true')
})

it('calls segment button onClick if it is passed', async () => {
const user = userEvent.setup()
const handleClick = jest.fn()
Expand Down
34 changes: 19 additions & 15 deletions src/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useRef} from 'react'
import React, {useRef, useState} from 'react'
import Button, {SegmentedControlButtonProps} from './SegmentedControlButton'
import SegmentedControlIconButton, {SegmentedControlIconButtonProps} from './SegmentedControlIconButton'
import {ActionList, ActionMenu, useTheme} from '..'
Expand Down Expand Up @@ -51,14 +51,21 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
}) => {
const segmentedControlContainerRef = useRef<HTMLUListElement>(null)
const {theme} = useTheme()
const isUncontrolled =
onChange === undefined ||
React.Children.toArray(children).some(
child => React.isValidElement<SegmentedControlButtonProps>(child) && child.props.defaultSelected !== undefined
)
const responsiveVariant = useResponsiveValue(variant, 'default')
const isFullWidth = useResponsiveValue(fullWidth, false)
const selectedSegments = React.Children.toArray(children).map(
child =>
React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child) && child.props.selected
)
const hasSelectedButton = selectedSegments.some(isSelected => isSelected)
const selectedIndex = hasSelectedButton ? selectedSegments.indexOf(true) : 0
const selectedIndexExternal = hasSelectedButton ? selectedSegments.indexOf(true) : 0
const [selectedIndexInternalState, setSelectedIndexInternalState] = useState<number>(selectedIndexExternal)
const selectedIndex = isUncontrolled ? selectedIndexInternalState : selectedIndexExternal
const selectedChild = React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(
React.Children.toArray(children)[selectedIndex]
)
Expand Down Expand Up @@ -108,18 +115,11 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
<ActionList.Item
key={`segmented-control-action-btn-${index}`}
selected={index === selectedIndex}
onSelect={
onChange
? (event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => {
onChange(index)
// TODO: figure out a way around the typecasting
child.props.onClick && child.props.onClick(event as React.MouseEvent<HTMLLIElement>)
}
: // TODO: figure out a way around the typecasting
(child.props.onClick as (
event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>
) => void)
}
onSelect={(event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => {
isUncontrolled && setSelectedIndexInternalState(index)
onChange && onChange(index)
child.props.onClick && child.props.onClick(event as React.MouseEvent<HTMLLIElement>)
}}
>
{ChildIcon && <ChildIcon />} {getChildText(child)}
</ActionList.Item>
Expand All @@ -146,9 +146,13 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
onClick: onChange
? (event: React.MouseEvent<HTMLButtonElement>) => {
onChange(index)
isUncontrolled && setSelectedIndexInternalState(index)
child.props.onClick && child.props.onClick(event)
}
: child.props.onClick,
: (event: React.MouseEvent<HTMLButtonElement>) => {
child.props.onClick && child.props.onClick(event)
isUncontrolled && setSelectedIndexInternalState(index)
},
selected: index === selectedIndex,
sx: {
'--separator-color':
Expand Down
4 changes: 3 additions & 1 deletion src/SegmentedControl/SegmentedControlButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {getSegmentedControlButtonStyles, getSegmentedControlListItemStyles} from
export type SegmentedControlButtonProps = {
/** The visible label rendered in the button */
children: string
/** Whether the segment is selected */
/** Whether the segment is selected. This is used for controlled `SegmentedControls`, and needs to be updated using the `onChange` handler on `SegmentedControl`. */
selected?: boolean
/** Whether the segment is selected. This is used for uncontrolled `SegmentedControls` to pick one `SegmentedControlButton` that is selected on the initial render. */
defaultSelected?: boolean
/** The leading icon comes before item label */
leadingIcon?: React.FunctionComponent<React.PropsWithChildren<IconProps>>
} & SxProp &
Expand Down
4 changes: 3 additions & 1 deletion src/SegmentedControl/SegmentedControlIconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ export type SegmentedControlIconButtonProps = {
'aria-label': string
/** The icon that represents the segmented control item */
icon: React.FunctionComponent<React.PropsWithChildren<IconProps>>
/** Whether the segment is selected */
/** Whether the segment is selected. This is used for controlled SegmentedControls, and needs to be updated using the onChange handler on SegmentedControl. */
selected?: boolean
/** Whether the segment is selected. This is used for uncontrolled SegmentedControls to pick one SegmentedControlButton that is selected on the initial render. */
defaultSelected?: boolean
} & SxProp &
HTMLAttributes<HTMLButtonElement | HTMLLIElement>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ exports[`SegmentedControl renders consistently 1`] = `
<button
aria-current={true}
className="c2"
onClick={[Function]}
>
<span
className="segmentedControl-content"
Expand All @@ -361,6 +362,7 @@ exports[`SegmentedControl renders consistently 1`] = `
<button
aria-current={false}
className="c4"
onClick={[Function]}
>
<span
className="segmentedControl-content"
Expand All @@ -379,6 +381,7 @@ exports[`SegmentedControl renders consistently 1`] = `
<button
aria-current={false}
className="c5"
onClick={[Function]}
>
<span
className="segmentedControl-content"
Expand Down

0 comments on commit 3571658

Please sign in to comment.