Skip to content

Make SegmentedControl uncontrolled by default #2189

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

Merged
merged 20 commits into from
Aug 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ee13a49
makes SegmentedControl uncontrolled by default
mperrotti Jul 26, 2022
890c431
corrects onChange in SegmentedControl prop table
mperrotti Jul 26, 2022
3aede3d
adds changeset
mperrotti Jul 26, 2022
0092cf2
Merge branch 'main' of github.com:primer/react into mp/segmented-cont…
mperrotti Aug 1, 2022
da4c756
fixes broken test
mperrotti Aug 1, 2022
c513be5
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 2, 2022
273636b
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 2, 2022
abe0cd0
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 2, 2022
81e1e5b
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 3, 2022
b5ff47a
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 3, 2022
176052b
adds selected and defaultSelected props, adds controlled SegmentedCon…
mperrotti Aug 3, 2022
3a70c7f
fixes docs 'Controlled' example
mperrotti Aug 3, 2022
4ee30f5
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 5, 2022
3c649ab
Merge branch 'main' of github.com:primer/react into mp/segmented-cont…
mperrotti Aug 5, 2022
d77d5f1
Merge branch 'mp/segmented-control-default-uncontrolled' of github.co…
mperrotti Aug 5, 2022
ad917b7
Merge branch 'main' of github.com:primer/react into mp/segmented-cont…
mperrotti Aug 5, 2022
910698b
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 8, 2022
f8716e0
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 10, 2022
997cab0
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 11, 2022
c50952d
Merge branch 'main' into mp/segmented-control-default-uncontrolled
mperrotti Aug 11, 2022
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
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