diff --git a/.changeset/empty-crews-impress.md b/.changeset/empty-crews-impress.md new file mode 100644 index 00000000000..2b7a16d2d31 --- /dev/null +++ b/.changeset/empty-crews-impress.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Convert SegmentedControl to use CSS modules behind feature flag diff --git a/packages/react/src/SegmentedControl/SegmentedControl.module.css b/packages/react/src/SegmentedControl/SegmentedControl.module.css new file mode 100644 index 00000000000..1b0c3652864 --- /dev/null +++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css @@ -0,0 +1,199 @@ +.SegmentedControl { + display: inline-flex; + + /* TODO: use primitive `control.{small|medium}.size` when it is available */ + height: 32px; + padding: 0; + margin: 0; + font-size: var(--text-body-size-medium); + background-color: var(--controlTrack-bgColor-rest); + border: var(--borderWidth-thin) solid var(--controlTrack-borderColor-rest, transparent); + border-radius: var(--borderRadius-medium); + + &:where([data-full-width]) { + display: flex; + width: 100%; + } + + &:where([data-size='small']) { + /* TODO: use primitive `control.{small|medium}.size` when it is available */ + height: 28px; + font-size: var(--text-body-size-small); + } +} + +.Item { + position: relative; + display: block; + /* stylelint-disable-next-line primer/spacing */ + margin-top: -1px; + /* stylelint-disable-next-line primer/spacing */ + margin-bottom: -1px; + flex-grow: 1; + + &:not(:last-child) { + /* stylelint-disable-next-line primer/spacing */ + margin-right: 1px; + + &::after { + position: absolute; + top: var(--base-size-8); + right: calc(-1 * var(--base-size-2)); + bottom: var(--base-size-8); + width: 1px; + content: ''; + /* stylelint-disable-next-line primer/colors */ + background-color: var(--borderColor-default); + } + + &:has(+ [data-selected])::after, + &:where([data-selected])::after { + background-color: transparent; + } + } + + &:focus-within:has(:focus-visible) { + background-color: transparent; + } + + &:first-child { + /* stylelint-disable-next-line primer/spacing */ + margin-left: -1px; + } + + &:last-child { + /* stylelint-disable-next-line primer/spacing */ + margin-right: -1px; + } +} + +.Button { + /* TODO: use primitive `primer.control.medium.paddingInline.normal` when it is available */ + --segmented-control-button-inner-padding: 12px; + --segmented-control-button-bg-inset: 4px; + --segmented-control-outer-radius: var(--borderRadius-medium); + + width: 100%; + height: 100%; + /* stylelint-disable-next-line primer/spacing */ + padding: var(--segmented-control-button-bg-inset); + font-family: inherit; + font-size: inherit; + font-weight: var(--base-text-weight-normal); + color: currentColor; + cursor: pointer; + background-color: transparent; + border-color: transparent; + border-width: 0; + /* stylelint-disable-next-line primer/borders */ + border-radius: var(--segmented-control-outer-radius); + + & svg { + fill: var(--fgColor-muted); + } + + /* fallback :focus state */ + &:focus:not(:disabled) { + outline: var(--base-size-2) solid var(--fgColor-accent); + outline-offset: -1px; + box-shadow: none; + + /* remove fallback :focus if :focus-visible is supported */ + &:not(:focus-visible) { + outline: solid 1px transparent; + } + } + + /* default focus state */ + &:focus-visible:not(:disabled) { + outline: var(--base-size-2) solid var(--fgColor-accent); + outline-offset: -1px; + box-shadow: none; + } + + /* stylelint-disable-next-line selector-max-specificity */ + &:focus:focus-visible:not(:last-child)::after { + /* fixes an issue where the focus outline shows over the pseudo-element */ + width: 0; + } + + @media (pointer: coarse) { + &::before { + position: absolute; + top: 50%; + right: 0; + left: 0; + min-height: 44px; + content: ''; + transform: translateY(-50%); + } + } +} + +.IconButton { + /* TODO: use primitive `control.medium.size` when it is available instead of '32px' */ + width: 32px; + + .SegmentedControl:where([data-full-width]) & { + width: 100%; + } +} + +.Content { + display: flex; + height: 100%; + /* stylelint-disable-next-line primer/spacing */ + padding-right: calc(var(--segmented-control-button-inner-padding) - var(--segmented-control-button-bg-inset)); + /* stylelint-disable-next-line primer/spacing */ + padding-left: calc(var(--segmented-control-button-inner-padding) - var(--segmented-control-button-bg-inset)); + background-color: transparent; + border-color: transparent; + border-style: solid; + border-width: var(--borderWidth-thin); + + /* + innerRadius = outerRadius - distance/2 + https://stackoverflow.com/questions/2932146/math-problem-determine-the-corner-radius-of-an-inner-border-based-on-outer-corn + */ + /* stylelint-disable-next-line primer/borders */ + border-radius: calc(var(--segmented-control-outer-radius) - var(--segmented-control-button-bg-inset) / 2); + align-items: center; + justify-content: center; +} + +.Button[aria-current='true'] { + padding: 0; + font-weight: var(--base-text-weight-semibold); + + .Content { + /* stylelint-disable-next-line primer/spacing */ + padding-right: var(--segmented-control-button-inner-padding); + /* stylelint-disable-next-line primer/spacing */ + padding-left: var(--segmented-control-button-inner-padding); + background-color: var(--controlKnob-bgColor-rest); + border-color: var(--controlKnob-borderColor-rest); + /* stylelint-disable-next-line primer/borders */ + border-radius: var(--segmented-control-outer-radius); + } +} + +.Button:not([aria-current='true']) { + &:hover .Content { + background-color: var(--controlTrack-bgColor-hover); + } + + &:active .Content { + background-color: var(--controlTrack-bgColor-active); + } +} + +.Text::after { + display: block; + height: 0; + overflow: hidden; + font-weight: var(--base-text-weight-semibold); + pointer-events: none; + visibility: hidden; + content: attr(data-text); + user-select: none; +} diff --git a/packages/react/src/SegmentedControl/SegmentedControl.tsx b/packages/react/src/SegmentedControl/SegmentedControl.tsx index ef15a893627..ff3a0d70111 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.tsx @@ -15,10 +15,21 @@ import styled from 'styled-components' import {defaultSxProp} from '../utils/defaultSxProp' import {isElement} from 'react-is' +import classes from './SegmentedControl.module.css' + +import {toggleStyledComponent} from '../internal/utils/toggleStyledComponent' +import {useFeatureFlag} from '../FeatureFlags' +import {clsx} from 'clsx' +import {SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG} from './getSegmentedControlStyles' + // Needed because passing a ref to `Box` causes a type error -const SegmentedControlList = styled.ul` - ${sx}; -` +const SegmentedControlList = toggleStyledComponent( + SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG, + 'ul', + styled.ul` + ${sx}; + `, +) type SegmentedControlProps = { 'aria-label'?: string @@ -57,6 +68,7 @@ const Root: React.FC> = ({ size, sx: sxProp = defaultSxProp, variant = 'default', + className, ...rest }) => { const segmentedControlContainerRef = useRef(null) @@ -117,7 +129,9 @@ const Root: React.FC> = ({ return React.isValidElement(childArg) ? childArg.props['aria-label'] : null } - const listSx = merge(getSegmentedControlStyles({isFullWidth, size}), sxProp as SxProp) + + const enabled = useFeatureFlag(SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG) + const listSx = enabled ? sxProp : merge(getSegmentedControlStyles({isFullWidth, size}), sxProp as SxProp) if (!ariaLabel && !ariaLabelledby) { // eslint-disable-next-line no-console @@ -174,6 +188,9 @@ const Root: React.FC> = ({ aria-label={ariaLabel} aria-labelledby={ariaLabelledby} ref={segmentedControlContainerRef} + className={clsx(enabled && classes.SegmentedControl, className)} + data-full-width={isFullWidth || undefined} + data-size={size} {...rest} > {React.Children.map(children, (child, index) => { diff --git a/packages/react/src/SegmentedControl/SegmentedControlButton.tsx b/packages/react/src/SegmentedControl/SegmentedControlButton.tsx index 5332330db85..8b53f02bcdf 100644 --- a/packages/react/src/SegmentedControl/SegmentedControlButton.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControlButton.tsx @@ -5,10 +5,19 @@ import styled from 'styled-components' import Box from '../Box' import type {SxProp} from '../sx' import sx, {merge} from '../sx' -import {getSegmentedControlButtonStyles, getSegmentedControlListItemStyles} from './getSegmentedControlStyles' +import { + getSegmentedControlButtonStyles, + getSegmentedControlListItemStyles, + SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG, +} from './getSegmentedControlStyles' import {defaultSxProp} from '../utils/defaultSxProp' import {isElement} from 'react-is' import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles' +import {useFeatureFlag} from '../FeatureFlags' + +import classes from './SegmentedControl.module.css' +import {clsx} from 'clsx' +import {toggleStyledComponent} from '../internal/utils/toggleStyledComponent' export type SegmentedControlButtonProps = { /** The visible label rendered in the button */ @@ -22,31 +31,40 @@ export type SegmentedControlButtonProps = { } & SxProp & ButtonHTMLAttributes -const SegmentedControlButtonStyled = styled.button` - ${getGlobalFocusStyles('-1px')}; - ${sx}; -` +const SegmentedControlButtonStyled = toggleStyledComponent( + SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG, + 'button', + styled.button` + ${getGlobalFocusStyles('-1px')}; + ${sx}; + `, +) const SegmentedControlButton: React.FC> = ({ children, leadingIcon: LeadingIcon, selected, sx: sxProp = defaultSxProp, + className, ...rest }) => { - const mergedSx = merge(getSegmentedControlListItemStyles(), sxProp as SxProp) + const enabled = useFeatureFlag(SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG) + const mergedSx = enabled ? sxProp : merge(getSegmentedControlListItemStyles(), sxProp as SxProp) return ( - + - + {LeadingIcon && {isElement(LeadingIcon) ? LeadingIcon : }} - {children} + + {children} + diff --git a/packages/react/src/SegmentedControl/SegmentedControlIconButton.tsx b/packages/react/src/SegmentedControl/SegmentedControlIconButton.tsx index 89b877d8b6a..83986e3b317 100644 --- a/packages/react/src/SegmentedControl/SegmentedControlIconButton.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControlIconButton.tsx @@ -4,11 +4,20 @@ import type {IconProps} from '@primer/octicons-react' import styled from 'styled-components' import type {SxProp} from '../sx' import sx, {merge} from '../sx' -import {getSegmentedControlButtonStyles, getSegmentedControlListItemStyles} from './getSegmentedControlStyles' +import { + getSegmentedControlButtonStyles, + getSegmentedControlListItemStyles, + SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG, +} from './getSegmentedControlStyles' import Box from '../Box' import {defaultSxProp} from '../utils/defaultSxProp' import {isElement} from 'react-is' import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles' +import {useFeatureFlag} from '../FeatureFlags' + +import classes from './SegmentedControl.module.css' +import {clsx} from 'clsx' +import {toggleStyledComponent} from '../internal/utils/toggleStyledComponent' export type SegmentedControlIconButtonProps = { 'aria-label': string @@ -21,10 +30,14 @@ export type SegmentedControlIconButtonProps = { } & SxProp & ButtonHTMLAttributes -const SegmentedControlIconButtonStyled = styled.button` - ${getGlobalFocusStyles('-1px')}; - ${sx}; -` +const SegmentedControlIconButtonStyled = toggleStyledComponent( + SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG, + 'button', + styled.button` + ${getGlobalFocusStyles('-1px')}; + ${sx}; + `, +) // TODO: update this component to be accessible when we update the Tooltip component // - we wouldn't render tooltip content inside a pseudoelement @@ -37,26 +50,38 @@ export const SegmentedControlIconButton: React.FC { - const mergedSx = merge( - { - width: '32px', // TODO: use primitive `control.medium.size` when it is available - ...getSegmentedControlListItemStyles(), - }, - sxProp as SxProp, - ) + const enabled = useFeatureFlag(SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG) + const mergedSx = enabled + ? sxProp + : merge( + { + width: '32px', // TODO: use primitive `control.medium.size` when it is available + ...getSegmentedControlListItemStyles(), + }, + sxProp as SxProp, + ) return ( - + {/* TODO: Once the tooltip remediations are resolved (especially https://github.com/github/primer/issues/1909) - bring it back */} - {isElement(Icon) ? Icon : } + + {isElement(Icon) ? Icon : } + ) diff --git a/packages/react/src/SegmentedControl/getSegmentedControlStyles.ts b/packages/react/src/SegmentedControl/getSegmentedControlStyles.ts index 874be61e589..274305b2381 100644 --- a/packages/react/src/SegmentedControl/getSegmentedControlStyles.ts +++ b/packages/react/src/SegmentedControl/getSegmentedControlStyles.ts @@ -1,6 +1,8 @@ import {get} from '../constants' import type {SegmentedControlButtonProps} from './SegmentedControlButton' +export const SEGMENTED_CONTROL_CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_team' + export const directChildLayoutAdjustments = { ':first-child': { marginLeft: '-1px', @@ -25,7 +27,7 @@ export const borderedSegment = { } export const getSegmentedControlButtonStyles = ( - props?: Partial> & {isIconOnly?: boolean}, + props?: Partial>, ) => ({ '--segmented-control-button-inner-padding': '12px', // TODO: use primitive `primer.control.medium.paddingInline.normal` when it is available '--segmented-control-button-bg-inset': '4px',