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