From c703182f006f8800798aa70873a5b5d6410d09cd Mon Sep 17 00:00:00 2001 From: Josh Black Date: Mon, 8 Jun 2026 11:43:08 -0500 Subject: [PATCH] Move React Refresh utility exports out of components --- .../FilteredActionListLoaders.tsx | 17 +---- .../react/src/FilteredActionList/constants.ts | 15 +++++ .../src/KeybindingHint/components/Chord.tsx | 34 +--------- .../KeybindingHint/components/Sequence.tsx | 10 +-- .../src/KeybindingHint/components/utils.ts | 41 ++++++++++++ packages/react/src/Overlay/Overlay.tsx | 21 +----- packages/react/src/Overlay/constants.ts | 19 ++++++ packages/react/src/Timeline/Timeline.tsx | 13 +--- packages/react/src/Timeline/constants.ts | 11 ++++ packages/react/src/Token/TokenBase.tsx | 29 ++------- packages/react/src/Token/constants.ts | 10 +++ packages/react/src/Token/utils.ts | 14 ++++ .../react/src/UnderlineNav/UnderlineNav.tsx | 6 +- packages/react/src/UnderlineNav/utils.ts | 6 ++ packages/react/src/hooks/MatchMedia.tsx | 56 ++++++++++++++++ packages/react/src/hooks/MatchMediaContext.ts | 10 +++ .../src/hooks/{useMedia.tsx => useMedia.ts} | 65 +------------------ 17 files changed, 203 insertions(+), 174 deletions(-) create mode 100644 packages/react/src/FilteredActionList/constants.ts create mode 100644 packages/react/src/KeybindingHint/components/utils.ts create mode 100644 packages/react/src/Overlay/constants.ts create mode 100644 packages/react/src/Timeline/constants.ts create mode 100644 packages/react/src/Token/constants.ts create mode 100644 packages/react/src/Token/utils.ts create mode 100644 packages/react/src/UnderlineNav/utils.ts create mode 100644 packages/react/src/hooks/MatchMedia.tsx create mode 100644 packages/react/src/hooks/MatchMediaContext.ts rename packages/react/src/hooks/{useMedia.tsx => useMedia.ts} (58%) diff --git a/packages/react/src/FilteredActionList/FilteredActionListLoaders.tsx b/packages/react/src/FilteredActionList/FilteredActionListLoaders.tsx index b28567e4072..02451d7243d 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListLoaders.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListLoaders.tsx @@ -2,24 +2,11 @@ import Spinner from '../Spinner' import {Stack} from '../Stack/Stack' import {SkeletonBox} from '../Skeleton/SkeletonBox' import classes from './FilteredActionListLoaders.module.css' +import {FilteredActionListLoadingType, FilteredActionListLoadingTypes} from './constants' import type {JSX} from 'react' -export class FilteredActionListLoadingType { - public name: string - public appearsInBody: boolean - - constructor(name: string, appearsInBody: boolean) { - this.name = name - this.appearsInBody = appearsInBody - } -} - -export const FilteredActionListLoadingTypes = { - bodySpinner: new FilteredActionListLoadingType('body-spinner', true), - bodySkeleton: new FilteredActionListLoadingType('body-skeleton', true), - input: new FilteredActionListLoadingType('input', false), -} +export {FilteredActionListLoadingType, FilteredActionListLoadingTypes} const SKELETON_ROW_HEIGHT = 24 const SKELETON_MIN_ROWS = 3 diff --git a/packages/react/src/FilteredActionList/constants.ts b/packages/react/src/FilteredActionList/constants.ts new file mode 100644 index 00000000000..e0b16ee1170 --- /dev/null +++ b/packages/react/src/FilteredActionList/constants.ts @@ -0,0 +1,15 @@ +export class FilteredActionListLoadingType { + public name: string + public appearsInBody: boolean + + constructor(name: string, appearsInBody: boolean) { + this.name = name + this.appearsInBody = appearsInBody + } +} + +export const FilteredActionListLoadingTypes = { + bodySpinner: new FilteredActionListLoadingType('body-spinner', true), + bodySkeleton: new FilteredActionListLoadingType('body-skeleton', true), + input: new FilteredActionListLoadingType('input', false), +} diff --git a/packages/react/src/KeybindingHint/components/Chord.tsx b/packages/react/src/KeybindingHint/components/Chord.tsx index 87de9189c57..11120d20ffc 100644 --- a/packages/react/src/KeybindingHint/components/Chord.tsx +++ b/packages/react/src/KeybindingHint/components/Chord.tsx @@ -2,35 +2,9 @@ import {Fragment} from 'react' import Text from '../../Text' import type {KeybindingHintProps} from '../props' import {Key} from './Key' -import {accessibleKeyName} from '../key-names' -import type {Platform} from '../platform' import {clsx} from 'clsx' import classes from './Chord.module.css' - -/** - * Consistent sort order for modifier keys. There should never be more than one non-modifier - * key in a chord, so we don't need to worry about sorting those - we just put them at - * the end. - */ -const keySortPriorities: Partial> = { - control: 1, - meta: 2, - alt: 3, - option: 4, - shift: 5, - function: 6, -} - -const keySortPriority = (priority: string) => keySortPriorities[priority] ?? Infinity - -const compareLowercaseKeys = (a: string, b: string) => keySortPriority(a) - keySortPriority(b) - -/** Split and sort the chord keys in standard order. */ -const splitChord = (chord: string) => - chord - .split('+') - .map(k => k.toLowerCase()) - .sort(compareLowercaseKeys) +import {accessibleChordString, splitChord} from './utils' export const Chord = ({keys, format = 'condensed', variant = 'normal', size = 'normal'}: KeybindingHintProps) => ( sequence.split(' ') @@ -21,8 +21,4 @@ export const Sequence = ({keys, ...chordProps}: KeybindingHintProps) => )) -/** Plain string version of `Sequence` for use in `aria` string attributes. */ -export const accessibleSequenceString = (sequence: string, platform: Platform) => - splitSequence(sequence) - .map(chord => accessibleChordString(chord, platform)) - .join(' then ') +export {accessibleSequenceString} diff --git a/packages/react/src/KeybindingHint/components/utils.ts b/packages/react/src/KeybindingHint/components/utils.ts new file mode 100644 index 00000000000..69785b9ba17 --- /dev/null +++ b/packages/react/src/KeybindingHint/components/utils.ts @@ -0,0 +1,41 @@ +import {accessibleKeyName} from '../key-names' +import type {Platform} from '../platform' + +/** + * Consistent sort order for modifier keys. There should never be more than one non-modifier + * key in a chord, so we don't need to worry about sorting those - we just put them at + * the end. + */ +const keySortPriorities: Partial> = { + control: 1, + meta: 2, + alt: 3, + option: 4, + shift: 5, + function: 6, +} + +const keySortPriority = (priority: string) => keySortPriorities[priority] ?? Infinity + +const compareLowercaseKeys = (a: string, b: string) => keySortPriority(a) - keySortPriority(b) + +/** Split and sort the chord keys in standard order. */ +export const splitChord = (chord: string) => + chord + .split('+') + .map(k => k.toLowerCase()) + .sort(compareLowercaseKeys) + +const splitSequence = (sequence: string) => sequence.split(' ') + +/** Plain string version of `Chord` for use in `aria` string attributes. */ +export const accessibleChordString = (chord: string, platform: Platform) => + splitChord(chord) + .map(key => accessibleKeyName(key, platform)) + .join(' ') + +/** Plain string version of `Sequence` for use in `aria` string attributes. */ +export const accessibleSequenceString = (sequence: string, platform: Platform) => + splitSequence(sequence) + .map(chord => accessibleChordString(chord, platform)) + .join(' then ') diff --git a/packages/react/src/Overlay/Overlay.tsx b/packages/react/src/Overlay/Overlay.tsx index 3307973eb40..24ae06b5557 100644 --- a/packages/react/src/Overlay/Overlay.tsx +++ b/packages/react/src/Overlay/Overlay.tsx @@ -10,6 +10,7 @@ import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../uti import classes from './Overlay.module.css' import {clsx} from 'clsx' import {useFeatureFlag} from '../FeatureFlags' +import {heightMap, widthMap} from './constants' type StyledOverlayProps = { width?: keyof typeof widthMap @@ -21,25 +22,7 @@ type StyledOverlayProps = { style?: React.CSSProperties } -export const heightMap = { - xsmall: '192px', - small: '256px', - medium: '320px', - large: '432px', - xlarge: '600px', - auto: 'auto', - initial: 'auto', // Passing 'initial' initially applies 'auto' - 'fit-content': 'fit-content', -} - -export const widthMap = { - small: '256px', - medium: '320px', - large: '480px', - xlarge: '640px', - xxlarge: '960px', - auto: 'auto', -} +export {heightMap, widthMap} const animationDuration = 200 function getSlideAnimationStartingVector(anchorSide?: AnchorSide): {x: number; y: number} { diff --git a/packages/react/src/Overlay/constants.ts b/packages/react/src/Overlay/constants.ts new file mode 100644 index 00000000000..3c209c5aaf9 --- /dev/null +++ b/packages/react/src/Overlay/constants.ts @@ -0,0 +1,19 @@ +export const heightMap = { + xsmall: '192px', + small: '256px', + medium: '320px', + large: '432px', + xlarge: '600px', + auto: 'auto', + initial: 'auto', + 'fit-content': 'fit-content', +} + +export const widthMap = { + small: '256px', + medium: '320px', + large: '480px', + xlarge: '640px', + xxlarge: '960px', + auto: 'auto', +} diff --git a/packages/react/src/Timeline/Timeline.tsx b/packages/react/src/Timeline/Timeline.tsx index c2a89a85db0..1932a779ac2 100644 --- a/packages/react/src/Timeline/Timeline.tsx +++ b/packages/react/src/Timeline/Timeline.tsx @@ -2,6 +2,7 @@ import {clsx} from 'clsx' import React from 'react' import {useFeatureFlag} from '../FeatureFlags' import classes from './Timeline.module.css' +import {TimelineBadgeVariants} from './constants' type StyledTimelineProps = {clipSidebar?: boolean | 'start' | 'end' | 'both'; className?: string} @@ -83,17 +84,7 @@ const TimelineItem = React.forwardRef = { - small: '16px', - medium: '20px', - large: '24px', - xlarge: '32px', -} - -export const defaultTokenSize: TokenSizeKeys = 'medium' +export {defaultTokenSize, isTokenInteractive, tokenSizes} +export type {TokenSizeKeys} export interface TokenBaseProps extends Omit, 'size' | 'id'> { @@ -48,19 +42,6 @@ export interface TokenBaseProps disabled?: boolean } -export const isTokenInteractive = ({ - as = 'span', - onClick, - onFocus, - tabIndex = -1, - disabled, -}: Pick, 'disabled' | 'as' | 'onClick' | 'onFocus' | 'tabIndex'>) => { - if (disabled) { - return false - } - return Boolean(onFocus || onClick || tabIndex > -1 || ['a', 'button'].includes(as)) -} - const TokenBase = React.forwardRef( ( { diff --git a/packages/react/src/Token/constants.ts b/packages/react/src/Token/constants.ts new file mode 100644 index 00000000000..33b0630f7c9 --- /dev/null +++ b/packages/react/src/Token/constants.ts @@ -0,0 +1,10 @@ +export type TokenSizeKeys = 'small' | 'medium' | 'large' | 'xlarge' + +export const tokenSizes: Record = { + small: '16px', + medium: '20px', + large: '24px', + xlarge: '32px', +} + +export const defaultTokenSize: TokenSizeKeys = 'medium' diff --git a/packages/react/src/Token/utils.ts b/packages/react/src/Token/utils.ts new file mode 100644 index 00000000000..5a74595533e --- /dev/null +++ b/packages/react/src/Token/utils.ts @@ -0,0 +1,14 @@ +import type {TokenBaseProps} from './TokenBase' + +export const isTokenInteractive = ({ + as = 'span', + onClick, + onFocus, + tabIndex = -1, + disabled, +}: Pick) => { + if (disabled) { + return false + } + return Boolean(onFocus || onClick || tabIndex > -1 || ['a', 'button'].includes(as)) +} diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index e4248a7035d..7dc1fb8eb9c 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -18,6 +18,7 @@ import CounterLabel from '../CounterLabel' import {invariant} from '../utils/invariant' import classes from './UnderlineNav.module.css' import {getAnchoredPosition} from '@primer/behaviors' +import {getValidChildren} from './utils' export type UnderlineNavProps = { children: React.ReactNode @@ -109,10 +110,7 @@ const overflowEffect = ( updateListAndMenu({items, menuItems}, iconsVisible, true) } -export const getValidChildren = (children: React.ReactNode) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[] -} +export {getValidChildren} const calculatePossibleItems = (childWidthArray: ChildWidthArray, navWidth: number, moreMenuWidth = 0) => { const widthToFit = navWidth - moreMenuWidth diff --git a/packages/react/src/UnderlineNav/utils.ts b/packages/react/src/UnderlineNav/utils.ts new file mode 100644 index 00000000000..698c99afffe --- /dev/null +++ b/packages/react/src/UnderlineNav/utils.ts @@ -0,0 +1,6 @@ +import React from 'react' + +export const getValidChildren = (children: React.ReactNode) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[] +} diff --git a/packages/react/src/hooks/MatchMedia.tsx b/packages/react/src/hooks/MatchMedia.tsx new file mode 100644 index 00000000000..07950efefd0 --- /dev/null +++ b/packages/react/src/hooks/MatchMedia.tsx @@ -0,0 +1,56 @@ +import type React from 'react' +import {useState} from 'react' +import {MatchMediaContext, type MediaQueryFeatures} from './MatchMediaContext' + +type MatchMediaProps = { + children: React.ReactNode + features?: MediaQueryFeatures +} + +const defaultFeatures = {} + +/** + * Use `MatchMedia` to emulate media conditions by passing in feature + * queries to the `features` prop. If a component uses `useMedia` with the + * feature passed in to `MatchMedia` it will force its value to match what is + * provided to `MatchMedia` + * + * This should be used for development and documentation only in situations + * where devtools cannot emulate this feature + * + * @example + * + * + * + */ +export function MatchMedia({children, features = defaultFeatures}: MatchMediaProps) { + const value = useShallowObject(features) + return {children} +} + +type SimpleObject = { + [key: string]: boolean | number | string | null | undefined +} + +/** + * Utility hook to provide a stable identity for a "simple" object which + * contains only primitive values. This provides a `useMemo`-esque signature + * without dealing with shallow equality checks in the dependency array. + * + * Note (perf): this hook iterates through keys and values of the object if the + * shallow equality check is false each time the hook is called + */ +function useShallowObject(object: T): T { + const [value, setValue] = useState(object) + + if (value !== object) { + const match = Object.keys(object).every(key => { + return object[key] === value[key] + }) + if (!match) { + setValue(object) + } + } + + return value +} diff --git a/packages/react/src/hooks/MatchMediaContext.ts b/packages/react/src/hooks/MatchMediaContext.ts new file mode 100644 index 00000000000..c3cadc0028c --- /dev/null +++ b/packages/react/src/hooks/MatchMediaContext.ts @@ -0,0 +1,10 @@ +import {createContext} from 'react' + +export type MediaQueryFeatures = { + [key: string]: boolean | undefined +} + +// Used to keep track of overrides to specific media query features, this should +// be used for development and demo purposes to emulate specific features if +// unavailable through devtools +export const MatchMediaContext = createContext({}) diff --git a/packages/react/src/hooks/useMedia.tsx b/packages/react/src/hooks/useMedia.ts similarity index 58% rename from packages/react/src/hooks/useMedia.tsx rename to packages/react/src/hooks/useMedia.ts index 2e5f999ee40..d70d9bccfb7 100644 --- a/packages/react/src/hooks/useMedia.tsx +++ b/packages/react/src/hooks/useMedia.ts @@ -1,6 +1,7 @@ -import React, {createContext, useContext, useState, useEffect} from 'react' +import React, {useContext, useEffect} from 'react' import {canUseDOM} from '../utils/environment' import {warning} from '../utils/warning' +import {MatchMediaContext} from './MatchMediaContext' /** * `useMedia` will use the given `mediaQueryString` with `matchMedia` to @@ -84,64 +85,4 @@ export function useMedia(mediaQueryString: string, defaultState?: boolean) { return matches } -type MediaQueryFeatures = { - [key: string]: boolean | undefined -} - -// Used to keep track of overrides to specific media query features, this should -// be used for development and demo purposes to emulate specific features if -// unavailable through devtools -const MatchMediaContext = createContext({}) - -type MatchMediaProps = { - children: React.ReactNode - features?: MediaQueryFeatures -} - -const defaultFeatures = {} - -/** - * Use `MatchMedia` to emulate media conditions by passing in feature - * queries to the `features` prop. If a component uses `useMedia` with the - * feature passed in to `MatchMedia` it will force its value to match what is - * provided to `MatchMedia` - * - * This should be used for development and documentation only in situations - * where devtools cannot emulate this feature - * - * @example - * - * - * - */ -export function MatchMedia({children, features = defaultFeatures}: MatchMediaProps) { - const value = useShallowObject(features) - return {children} -} - -type SimpleObject = { - [key: string]: boolean | number | string | null | undefined -} - -/** - * Utility hook to provide a stable identity for a "simple" object which - * contains only primitive values. This provides a `useMemo`-esque signature - * without dealing with shallow equality checks in the dependency array. - * - * Note (perf): this hook iterates through keys and values of the object if the - * shallow equality check is false each time the hook is called - */ -function useShallowObject(object: T): T { - const [value, setValue] = useState(object) - - if (value !== object) { - const match = Object.keys(object).every(key => { - return object[key] === value[key] - }) - if (!match) { - setValue(object) - } - } - - return value -} +export {MatchMedia} from './MatchMedia'