diff --git a/.changeset/bright-timers-jog.md b/.changeset/bright-timers-jog.md
new file mode 100644
index 00000000000..712bc028e8c
--- /dev/null
+++ b/.changeset/bright-timers-jog.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+Adds responsive behavior to SegmentedControl's `fullWidth` prop.
diff --git a/docs/content/SegmentedControl.mdx b/docs/content/SegmentedControl.mdx
index 55084f64f74..62c4f3b712d 100644
--- a/docs/content/SegmentedControl.mdx
+++ b/docs/content/SegmentedControl.mdx
@@ -151,7 +151,16 @@ description: Use a segmented control to let users select an option from a short
-
+
diff --git a/src/SegmentedControl/SegmentedControl.test.tsx b/src/SegmentedControl/SegmentedControl.test.tsx
index 67eb0f5c02d..3a0a094b008 100644
--- a/src/SegmentedControl/SegmentedControl.test.tsx
+++ b/src/SegmentedControl/SegmentedControl.test.tsx
@@ -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: () => },
diff --git a/src/SegmentedControl/SegmentedControl.tsx b/src/SegmentedControl/SegmentedControl.tsx
index 852f4c6be8a..9336c0097dc 100644
--- a/src/SegmentedControl/SegmentedControl.tsx
+++ b/src/SegmentedControl/SegmentedControl.tsx
@@ -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'
@@ -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
/** 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>
} & 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
})
@@ -43,13 +43,8 @@ const Root: React.FC> = ({
}) => {
const segmentedControlContainerRef = useRef(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(child) && child.props.selected
@@ -79,13 +74,7 @@ const Root: React.FC> = ({
return React.isValidElement(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) {
@@ -113,37 +102,7 @@ const Root: React.FC> = ({
)
}
- // 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
{getChildText(selectedChild)}
@@ -211,7 +170,7 @@ const Root: React.FC> = ({
// Render the 'hideLabels' variant of the SegmentedControlButton
if (
- getVariantToRender() === 'hideLabels' &&
+ responsiveVariant === 'hideLabels' &&
React.isValidElement(child) &&
child.type === Button
) {
diff --git a/src/SegmentedControl/examples.stories.tsx b/src/SegmentedControl/examples.stories.tsx
index 163d1ae71f3..1e443aa41c1 100644
--- a/src/SegmentedControl/examples.stories.tsx
+++ b/src/SegmentedControl/examples.stories.tsx
@@ -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 = [
@@ -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',
@@ -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',
@@ -122,7 +112,11 @@ export default {
} as Meta
export const Default = (args: Args) => (
-
+
Preview
Raw
Blame
@@ -136,7 +130,12 @@ export const Controlled = (args: Args) => {
}
return (
-
+
Preview
Raw
Blame
@@ -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
-
+
Preview
@@ -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
-
+
diff --git a/src/__tests__/hooks/useMatchMedia.test.tsx b/src/__tests__/hooks/useMatchMedia.test.tsx
deleted file mode 100644
index 5ca9be5860a..00000000000
--- a/src/__tests__/hooks/useMatchMedia.test.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import {renderHook, act} from '@testing-library/react-hooks'
-import MatchMediaMock from 'jest-matchmedia-mock'
-import useMatchMedia, {viewportRanges} from '../../hooks/useMatchMedia'
-import {ViewportRangeKeys} from '../../utils/types/ViewportRangeKeys'
-
-let matchMedia: MatchMediaMock
-
-describe('useMatchMedia', () => {
- beforeAll(() => {
- matchMedia = new MatchMediaMock()
- })
-
- afterEach(() => {
- matchMedia.clear()
- })
-
- it('should return true when the media query matches a viewport range key that is passed', () => {
- for (const viewportRangeKey of Object.keys(viewportRanges)) {
- matchMedia = new MatchMediaMock()
- const {result} = renderHook(() => useMatchMedia(viewportRangeKey as ViewportRangeKeys))
-
- act(() => {
- matchMedia.useMediaQuery(viewportRanges[viewportRangeKey as ViewportRangeKeys])
- })
-
- expect(result.current).toBe(true)
- matchMedia.clear()
- }
- })
- it('should return false when the media query does not match a viewport range key that is passed', () => {
- const {result} = renderHook(() => useMatchMedia('wide'))
-
- act(() => {
- matchMedia.useMediaQuery(viewportRanges.narrow)
- })
-
- expect(result.current).toBe(false)
- })
- it('should return an object where there is one matching viewport range in the array that is passed', () => {
- const {result} = renderHook(() => useMatchMedia(['wide']))
-
- act(() => {
- matchMedia.useMediaQuery(viewportRanges.wide)
- })
-
- expect(result.current).toStrictEqual({
- narrow: false,
- narrowLandscape: false,
- regular: false,
- wide: true,
- landscape: false,
- portrait: false
- })
- })
-})
diff --git a/src/hooks/useMatchMedia.ts b/src/hooks/useMatchMedia.ts
deleted file mode 100644
index 1f0a641d2d8..00000000000
--- a/src/hooks/useMatchMedia.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import {useLayoutEffect, useReducer} from 'react'
-import {ViewportRangeKeys} from '../utils/types/ViewportRangeKeys'
-
-// TODO: import from Primer Primitives https://primer.style/primitives/spacing/
-// TODO: file an issue that the proposed values for `narrow` and `narrowLandscape` don't work for JS media queries
-export const viewportRanges = {
- narrow: '(max-width: 47.9999rem)',
- narrowLandscape: '(max-width: 63.2499rem)',
- regular: '(min-width: 48rem)',
- wide: '(min-width: 90rem)',
- portrait: '(orientation: portrait)',
- landscape: '(orientation: landscape)'
-}
-
-const initialState = {
- narrow: undefined,
- narrowLandscape: undefined,
- regular: undefined,
- wide: undefined,
- portrait: undefined,
- landscape: undefined
-}
-
-const reducer = (
- state: Record,
- action: {type: ViewportRangeKeys; payload: boolean | undefined}
-) => {
- const {type, payload} = action
- return {...state, [type]: payload}
-}
-
-const useMatchMedia = (viewportRange: ViewportRangeKeys | ViewportRangeKeys[]) => {
- const [state, dispatch] = useReducer(reducer, initialState)
-
- useLayoutEffect(() => {
- // This could have gone in global scope, but then Jest can't mock `window.matchMedia`
- const mediaQueries = Object.keys(initialState).reduce>(
- (acc, viewportRangeKey) => {
- acc[viewportRangeKey as ViewportRangeKeys] = window.matchMedia(
- viewportRanges[viewportRangeKey as ViewportRangeKeys]
- )
- return acc
- },
- {
- narrow: undefined,
- narrowLandscape: undefined,
- regular: undefined,
- wide: undefined,
- portrait: undefined,
- landscape: undefined
- }
- )
- const matchMediaListeners = {
- narrow: () => {
- dispatch({type: 'narrow', payload: mediaQueries.narrow?.matches})
- },
- narrowLandscape: () => {
- dispatch({type: 'narrowLandscape', payload: mediaQueries.narrowLandscape?.matches})
- },
- regular: () => {
- dispatch({type: 'regular', payload: mediaQueries.regular?.matches})
- },
- wide: () => {
- dispatch({type: 'wide', payload: mediaQueries.wide?.matches})
- },
- portrait: () => {
- dispatch({type: 'portrait', payload: mediaQueries.portrait?.matches})
- },
- landscape: () => {
- dispatch({type: 'landscape', payload: mediaQueries.landscape?.matches})
- }
- }
-
- // if a single string was passed for viewport range, just dispatch an update to that viewport range
- if (typeof viewportRange === 'string' && state[viewportRange] !== mediaQueries[viewportRange]?.matches) {
- dispatch({type: viewportRange, payload: mediaQueries[viewportRange]?.matches})
- }
-
- // if a responsive object was passed for viewport range:
- // - add matchMedia event listeners to all viewport ranges
- // - dispatch updates to all viewport ranges
- for (const rangeKey of Object.keys(mediaQueries) as ViewportRangeKeys[]) {
- mediaQueries[rangeKey as ViewportRangeKeys]?.addEventListener(
- 'change',
- matchMediaListeners[rangeKey as ViewportRangeKeys]
- )
-
- if (state[rangeKey] !== mediaQueries[rangeKey]?.matches) {
- dispatch({type: rangeKey, payload: mediaQueries[rangeKey]?.matches})
- }
- }
-
- // remove matchMedia event listeners on unmount
- return () => {
- for (const rangeKey of Object.keys(mediaQueries)) {
- mediaQueries[rangeKey as ViewportRangeKeys]?.removeEventListener(
- 'change',
- matchMediaListeners[rangeKey as ViewportRangeKeys]
- )
- }
- }
- }, [state, viewportRange])
-
- return typeof viewportRange === 'string' ? state[viewportRange] : state
-}
-
-export default useMatchMedia