Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.
11 changes: 10 additions & 1 deletion docs/content/SegmentedControl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,15 @@ 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
width?: 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 @@ -164,6 +172,7 @@ description: Use a segmented control to let users select an option from a short
type="'default' | {
narrow?: 'hideLabels' | 'dropdown' | 'default'
regular?: 'hideLabels' | 'dropdown' | 'default'
width?: '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 {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 | Partial<Record<WidthOnlyViewportRangeKeys, 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<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<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<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<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