Skip to content

SegmentedControl - make fullWidth prop responsive #2191

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 13 commits into from
Aug 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/bright-timers-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Adds responsive behavior to SegmentedControl's `fullWidth` prop.
21 changes: 16 additions & 5 deletions docs/content/SegmentedControl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,16 @@ description: Use a segmented control to let users select an option from a short
<PropsTableRow name="aria-label" type="string" />
<PropsTableRow name="aria-labelledby" type="string" />
<PropsTableRow name="aria-describedby" type="string" />
<PropsTableRow name="fullWidth" type="boolean" description="Whether the control fills the width of its parent" />
<PropsTableRow
name="fullWidth"
type={`| boolean
| {
narrow?: boolean
regular?: boolean
wide?: boolean
}`}
description="Whether the control fills the width of its parent"
/>
<PropsTableRow name="loading" type="boolean" description="Whether the selected segment is being calculated" />
<PropsTableRow
name="onChange"
Expand All @@ -161,10 +170,12 @@ description: Use a segmented control to let users select an option from a short
/>
<PropsTableRow
name="variant"
type="'default' | {
narrow?: 'hideLabels' | 'dropdown' | 'default'
regular?: 'hideLabels' | 'dropdown' | 'default'
}"
type={`| 'default'
| {
narrow?: 'hideLabels' | 'dropdown' | 'default'
regular?: 'hideLabels' | 'dropdown' | 'default'
wide?: 'hideLabels' | 'dropdown' | 'default'
}`}
defaultValue="'default'"
description="Configure alternative ways to render the control when it gets rendered in tight spaces"
/>
Expand Down
2 changes: 1 addition & 1 deletion src/SegmentedControl/SegmentedControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {SegmentedControl} from '.' // TODO: update import when we move this to t
import theme from '../theme'
import {BaseStyles, SSRProvider, ThemeProvider} from '..'
import {act} from 'react-test-renderer'
import {viewportRanges} from '../hooks/useMatchMedia'
import {viewportRanges} from '../hooks/useResponsiveValue'

const segmentData = [
{label: 'Preview', id: 'preview', iconLabel: 'EyeIcon', icon: () => <EyeIcon aria-label="EyeIcon" />},
Expand Down
59 changes: 9 additions & 50 deletions src/SegmentedControl/SegmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Button, {SegmentedControlButtonProps} from './SegmentedControlButton'
import SegmentedControlIconButton, {SegmentedControlIconButtonProps} from './SegmentedControlIconButton'
import {ActionList, ActionMenu, Box, useTheme} from '..'
import {merge, SxProp} from '../sx'
import useMatchMedia from '../hooks/useMatchMedia'
import {ResponsiveValue, useResponsiveValue} from '../hooks/useResponsiveValue'
import {ViewportRangeKeys} from '../utils/types/ViewportRangeKeys'
import {FocusKeys, FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'

Expand All @@ -14,20 +14,20 @@ type SegmentedControlProps = {
'aria-labelledby'?: string
'aria-describedby'?: string
/** Whether the control fills the width of its parent */
fullWidth?: boolean
fullWidth?: boolean | ResponsiveValue<boolean>
/** The handler that gets called when a segment is selected */
onChange?: (selectedIndex: number) => void
/** Configure alternative ways to render the control when it gets rendered in tight spaces */
variant?: 'default' | Partial<Record<WidthOnlyViewportRangeKeys, 'hideLabels' | 'dropdown' | 'default'>>
} & SxProp

const getSegmentedControlStyles = (props?: SegmentedControlProps) => ({
const getSegmentedControlStyles = (isFullWidth?: boolean) => ({
backgroundColor: 'segmentedControl.bg',
borderColor: 'border.default',
borderRadius: 2,
borderStyle: 'solid',
borderWidth: 1,
display: props?.fullWidth ? 'flex' : 'inline-flex',
display: isFullWidth ? 'flex' : 'inline-flex',
height: '32px' // TODO: use primitive `control.medium.size` when it is available
})

Expand All @@ -43,13 +43,8 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
}) => {
const segmentedControlContainerRef = useRef<HTMLSpanElement>(null)
const {theme} = useTheme()
const mediaQueryMatches = useMatchMedia(Object.keys(variant || {}) as WidthOnlyViewportRangeKeys[])
const mediaQueryMatchesKeys = mediaQueryMatches
? (Object.keys(mediaQueryMatches) as WidthOnlyViewportRangeKeys[]).filter(
viewportRangeKey => typeof mediaQueryMatches === 'object' && mediaQueryMatches[viewportRangeKey]
)
: []

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
Expand Down Expand Up @@ -79,13 +74,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({

return React.isValidElement<SegmentedControlIconButtonProps>(childArg) ? childArg.props['aria-label'] : null
}

const sx = merge(
getSegmentedControlStyles({
fullWidth
}),
sxProp as SxProp
)
const sx = merge(getSegmentedControlStyles(isFullWidth), sxProp as SxProp)

const focusInStrategy: FocusZoneHookSettings['focusInStrategy'] = () => {
if (segmentedControlContainerRef.current) {
Expand Down Expand Up @@ -113,37 +102,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
)
}

// Since we can have multiple media query matches for `variant` (e.g.: 'regular' and 'wide'),
// we need to pick which variant we actually show.
const getVariantToRender = () => {
// If no variant was passed, return 'default'
if (!variant || variant === 'default') {
return 'default'
}

// Prioritize viewport range keys that override the 'regular' range in order of
// priorty from lowest to highest
// Orientation keys beat 'wide' because they are more specific.
const viewportRangeKeysByPriority: ViewportRangeKeys[] = ['wide', 'portrait', 'landscape']

// Filter the viewport range keys to only include those that:
// - are in the priority list
// - have a variant set
const variantPriorityKeys = mediaQueryMatchesKeys.filter(key => {
return viewportRangeKeysByPriority.includes(key) && variant[key]
})

// If we have to pick from multiple variants and one or more of them overrides 'regular',
// use the last key from the filtered list.
if (mediaQueryMatchesKeys.length > 1 && variantPriorityKeys.length) {
return variant[variantPriorityKeys[variantPriorityKeys.length - 1]]
}

// Otherwise, use the variant for the first matching media query
return typeof mediaQueryMatches === 'object' && variant[mediaQueryMatchesKeys[0]]
}

return getVariantToRender() === 'dropdown' ? (
return responsiveVariant === 'dropdown' ? (
// Render the 'dropdown' variant of the SegmentedControlButton or SegmentedControlIconButton
<ActionMenu>
<ActionMenu.Button leadingIcon={getChildIcon(selectedChild)}>{getChildText(selectedChild)}</ActionMenu.Button>
Expand Down Expand Up @@ -211,7 +170,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({

// Render the 'hideLabels' variant of the SegmentedControlButton
if (
getVariantToRender() === 'hideLabels' &&
responsiveVariant === 'hideLabels' &&
React.isValidElement<SegmentedControlButtonProps>(child) &&
child.type === Button
) {
Expand Down
99 changes: 53 additions & 46 deletions src/SegmentedControl/examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import Box from '../Box'
type ResponsiveVariantOptions = 'dropdown' | 'hideLabels' | 'default'
type Args = {
fullWidth?: boolean
fullWidthAtNarrow?: boolean
fullWidthAtRegular?: boolean
fullWidthAtWide?: boolean
variantAtNarrow: ResponsiveVariantOptions
variantAtNarrowLandscape: ResponsiveVariantOptions
variantAtRegular: ResponsiveVariantOptions
variantAtWide: ResponsiveVariantOptions
variantAtPortrait: ResponsiveVariantOptions
variantAtLandscape: ResponsiveVariantOptions
}

const excludedControlKeys = [
Expand All @@ -29,24 +29,20 @@ const excludedControlKeys = [

const variantOptions = ['dropdown', 'hideLabels', 'default']

const parseVarientFromArgs = (args: Args) => {
const {
variantAtNarrow,
variantAtNarrowLandscape,
variantAtRegular,
variantAtWide,
variantAtPortrait,
variantAtLandscape
} = args
return {
narrow: variantAtNarrow,
narrowLandscape: variantAtNarrowLandscape,
regular: variantAtRegular,
wide: variantAtWide,
portrait: variantAtPortrait,
landscape: variantAtLandscape
}
}
const parseVariantFromArgs = ({variantAtNarrow, variantAtRegular, variantAtWide}: Args) => ({
narrow: variantAtNarrow,
regular: variantAtRegular,
wide: variantAtWide
})

const parseFullWidthFromArgs = ({fullWidth, fullWidthAtNarrow, fullWidthAtRegular, fullWidthAtWide}: Args) =>
fullWidth
? fullWidth
: {
narrow: fullWidthAtNarrow,
regular: fullWidthAtRegular,
wide: fullWidthAtWide
}

export default {
title: 'SegmentedControl/examples',
Expand All @@ -58,48 +54,42 @@ export default {
type: 'boolean'
}
},
variantAtNarrow: {
name: 'variant.narrow',
defaultValue: 'default',
fullWidthAtNarrow: {
defaultValue: false,
control: {
type: 'radio',
options: variantOptions
type: 'boolean'
}
},
variantAtNarrowLandscape: {
name: 'variant.narrowLandscape',
defaultValue: 'default',
fullWidthAtRegular: {
defaultValue: false,
control: {
type: 'radio',
options: variantOptions
type: 'boolean'
}
},
variantAtRegular: {
name: 'variant.regular',
defaultValue: 'default',
fullWidthAtWide: {
defaultValue: false,
control: {
type: 'radio',
options: variantOptions
type: 'boolean'
}
},
variantAtWide: {
name: 'variant.wide',
variantAtNarrow: {
name: 'variant.narrow',
defaultValue: 'default',
control: {
type: 'radio',
options: variantOptions
}
},
variantAtPortrait: {
name: 'variant.portrait',
variantAtRegular: {
name: 'variant.regular',
defaultValue: 'default',
control: {
type: 'radio',
options: variantOptions
}
},
variantAtLandscape: {
name: 'variant.Landscape',
variantAtWide: {
name: 'variant.wide',
defaultValue: 'default',
control: {
type: 'radio',
Expand All @@ -122,7 +112,11 @@ export default {
} as Meta

export const Default = (args: Args) => (
<SegmentedControl aria-label="File view" {...args} variant={parseVarientFromArgs(args)}>
<SegmentedControl
aria-label="File view"
fullWidth={parseFullWidthFromArgs(args)}
variant={parseVariantFromArgs(args)}
>
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
<SegmentedControl.Button>Raw</SegmentedControl.Button>
<SegmentedControl.Button>Blame</SegmentedControl.Button>
Expand All @@ -136,7 +130,12 @@ export const Controlled = (args: Args) => {
}

return (
<SegmentedControl aria-label="File view" onChange={handleChange} {...args} variant={parseVarientFromArgs(args)}>
<SegmentedControl
aria-label="File view"
onChange={handleChange}
fullWidth={parseFullWidthFromArgs(args)}
variant={parseVariantFromArgs(args)}
>
<SegmentedControl.Button selected={selectedIndex === 0}>Preview</SegmentedControl.Button>
<SegmentedControl.Button selected={selectedIndex === 1}>Raw</SegmentedControl.Button>
<SegmentedControl.Button selected={selectedIndex === 2}>Blame</SegmentedControl.Button>
Expand All @@ -148,7 +147,11 @@ export const WithIconsAndLabels = (args: Args) => (
// padding needed to show Tooltip
// there is a separate initiative to change Tooltip to get positioned with useAnchoredPosition
<Box pt={5}>
<SegmentedControl aria-label="File view" {...args} variant={parseVarientFromArgs(args)}>
<SegmentedControl
aria-label="File view"
fullWidth={parseFullWidthFromArgs(args)}
variant={parseVariantFromArgs(args)}
>
<SegmentedControl.Button selected leadingIcon={EyeIcon}>
Preview
</SegmentedControl.Button>
Expand All @@ -162,7 +165,11 @@ export const IconsOnly = (args: Args) => (
// padding needed to show Tooltip
// there is a separate initiative to change Tooltip to get positioned with useAnchoredPosition
<Box pt={5}>
<SegmentedControl aria-label="File view" {...args} variant={parseVarientFromArgs(args)}>
<SegmentedControl
aria-label="File view"
fullWidth={parseFullWidthFromArgs(args)}
variant={parseVariantFromArgs(args)}
>
<SegmentedControl.IconButton selected icon={EyeIcon} aria-label="Preview" />
<SegmentedControl.IconButton icon={FileCodeIcon} aria-label="Raw" />
<SegmentedControl.IconButton icon={PeopleIcon} aria-label="Blame" />
Expand Down
55 changes: 0 additions & 55 deletions src/__tests__/hooks/useMatchMedia.test.tsx

This file was deleted.

Loading