From 161e3fd7c7ee64f07853405980b35b44eae61c9f Mon Sep 17 00:00:00 2001 From: Randall Krauskopf <104226843+randall-krauskopf@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:18:36 -0600 Subject: [PATCH] feat(UnderlinePanels): Convert UnderlinePanels to CSS modules behind team feature flag (#5357) * convert UnderlineItem * convert item list * additional css module migration * formatting * fix selectors * Migrate Loading Counter * key off of FF * add toggle for the tabContainer * update to take additional optional dependencies * update resize observer to key off of feature flag * fix lint issue --- .changeset/slimy-chefs-divide.md | 5 + .../UnderlinePanels.module.css | 10 + .../UnderlinePanels/UnderlinePanels.tsx | 61 +++- packages/react/src/hooks/useResizeObserver.ts | 9 +- .../UnderlineTabbedInterface.module.css | 149 ++++++++ .../components/UnderlineTabbedInterface.tsx | 334 ++++++++++++------ 6 files changed, 440 insertions(+), 128 deletions(-) create mode 100644 .changeset/slimy-chefs-divide.md create mode 100644 packages/react/src/experimental/UnderlinePanels/UnderlinePanels.module.css create mode 100644 packages/react/src/internal/components/UnderlineTabbedInterface.module.css diff --git a/.changeset/slimy-chefs-divide.md b/.changeset/slimy-chefs-divide.md new file mode 100644 index 00000000000..1c69f707b90 --- /dev/null +++ b/.changeset/slimy-chefs-divide.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Convert UnderlinePanels to CSS modules behind feature flags diff --git a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.module.css b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.module.css new file mode 100644 index 00000000000..9e4905c2c7f --- /dev/null +++ b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.module.css @@ -0,0 +1,10 @@ +.StyledUnderlineWrapper { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: auto; +} + +.StyledUnderlineWrapper[data-icons-visible='false'] [data-component='icon'] { + display: none; +} diff --git a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx index b683cb35054..2224d77d8cc 100644 --- a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx +++ b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx @@ -1,5 +1,6 @@ import React, {Children, isValidElement, cloneElement, useState, useRef, type FC, type PropsWithChildren} from 'react' import {TabContainerElement} from '@github/tab-container-element' +import type {IconProps} from '@primer/octicons-react' import {createComponent} from '../../utils/create-component' import { StyledUnderlineItemList, @@ -10,11 +11,15 @@ import { import Box, {type BoxProps} from '../../Box' import {useId} from '../../hooks' import {invariant} from '../../utils/invariant' -import type {IconProps} from '@primer/octicons-react' import {merge, type BetterSystemStyleObject, type SxProp} from '../../sx' import {defaultSxProp} from '../../utils/defaultSxProp' import {useResizeObserver, type ResizeObserverEntry} from '../../hooks/useResizeObserver' import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' +import {useFeatureFlag} from '../../FeatureFlags' +import classes from './UnderlinePanels.module.css' +import {toggleStyledComponent} from '../../internal/utils/toggleStyledComponent' + +const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_team' export type UnderlinePanelsProps = { /** @@ -59,6 +64,12 @@ export type PanelProps = Omit const TabContainerComponent = createComponent(TabContainerElement, 'tab-container') +const StyledTabContainerComponent = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'tab-container', + TabContainerComponent, +) + const UnderlinePanels: FC = ({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, @@ -102,6 +113,8 @@ const UnderlinePanels: FC = ({ ) const tabsHaveIcons = tabs.current.some(tab => React.isValidElement(tab) && tab.props.icon) + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + // this is a workaround to get the list's width on the first render const [listWidth, setListWidth] = useState(0) useIsomorphicLayoutEffect(() => { @@ -114,15 +127,19 @@ const UnderlinePanels: FC = ({ // when the wrapper resizes, check if the icons should be visible // by comparing the wrapper width to the list width - useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { - if (!tabsHaveIcons) { - return - } - - const wrapperWidth = resizeObserverEntries[0].contentRect.width - - setIconsVisible(wrapperWidth > listWidth) - }, wrapperRef) + useResizeObserver( + (resizeObserverEntries: ResizeObserverEntry[]) => { + if (!tabsHaveIcons) { + return + } + + const wrapperWidth = resizeObserverEntries[0].contentRect.width + + setIconsVisible(wrapperWidth > listWidth) + }, + wrapperRef, + [enabled], + ) if (__DEV__) { // only one tab can be selected at a time @@ -141,8 +158,28 @@ const UnderlinePanels: FC = ({ ) } + if (enabled) { + return ( + + + + {tabs.current} + + + {tabPanels.current} + + ) + } + return ( - + = ({ {tabPanels.current} - + ) } diff --git a/packages/react/src/hooks/useResizeObserver.ts b/packages/react/src/hooks/useResizeObserver.ts index 2fdd18326e9..32a0d496e00 100644 --- a/packages/react/src/hooks/useResizeObserver.ts +++ b/packages/react/src/hooks/useResizeObserver.ts @@ -9,7 +9,11 @@ export interface ResizeObserverEntry { contentRect: DOMRectReadOnly } -export function useResizeObserver(callback: ResizeObserverCallback, target?: RefObject) { +export function useResizeObserver( + callback: ResizeObserverCallback, + target?: RefObject, + depsArray: unknown[] = [], +) { const savedCallback = useRef(callback) useLayoutEffect(() => { @@ -31,5 +35,6 @@ export function useResizeObserver(callback: ResizeObserve return () => { observer.disconnect() } - }, [target]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [target, ...depsArray]) } diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css new file mode 100644 index 00000000000..2955f1a6aeb --- /dev/null +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -0,0 +1,149 @@ +.UnderlineWrapper { + display: flex; + /* stylelint-disable-next-line primer/spacing */ + padding-inline: var(--stack-padding-normal); + justify-content: flex-start; + align-items: center; + + /* make space for the underline */ + min-height: var(--control-xlarge-size, 48px); + + /* using a box-shadow instead of a border to accomodate 'overflow-y: hidden' on UnderlinePanels */ + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: inset 0 -1px var(--borderColor-muted); +} + +.UnderlineItemList { + position: relative; + display: flex; + padding: 0; + margin: 0; + white-space: nowrap; + list-style: none; + align-items: center; + gap: 8px; +} + +.UnderlineItem { + /* underline tab specific styles */ + position: relative; + display: inline-flex; + font: inherit; + font-size: var(--text-body-size-medium); + line-height: var(--text-body-lineHeight-medium, 1.4285); + color: var(--fgColor-default); + text-align: center; + text-decoration: none; + cursor: pointer; + background-color: transparent; + border: 0; + border-radius: var(--borderRadius-medium, var(--borderRadius-small)); + + /* button resets */ + appearance: none; + padding-inline: var(--base-size-8); + padding-block: var(--base-size-6); + align-items: center; + + @media (hover: hover) { + &:hover { + text-decoration: none; + background-color: var(--bgColor-neutral-muted); + transition: background-color 0.12s ease-out; + } + } +} + +.UnderlineItem:focus { + outline: 2px solid transparent; + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: inset 0 0 0 2px var(--fgColor-accent); + + /* where focus-visible is supported, remove the focus box-shadow */ + &:not(:focus-visible) { + box-shadow: none; + } +} + +.UnderlineItem:focus-visible { + outline: 2px solid transparent; + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: inset 0 0 0 2px var(--fgColor-accent); +} + +/* renders a visibly hidden "copy" of the label in bold, reserving box space for when label becomes bold on selected */ +.UnderlineItem [data-content]::before { + display: block; + height: 0; + font-weight: var(--base-text-weight-semibold); + white-space: nowrap; + visibility: hidden; + content: attr(data-content); +} + +.UnderlineItem [data-component='icon'] { + display: inline-flex; + color: var(--fgColor-muted); + align-items: center; + margin-inline-end: var(--base-size-8); +} + +.UnderlineItem [data-component='counter'] { + margin-inline-start: var(--base-size-8); + display: flex; + align-items: center; +} + +.UnderlineItem::after { + position: absolute; + right: 50%; + + /* TODO: see if we can simplify this positioning */ + + /* 48px total height / 2 (24px) + 1px */ + /* stylelint-disable-next-line primer/spacing */ + bottom: calc(50% - calc(var(--control-xlarge-size, var(--base-size-48)) / 2 + 1px)); + width: 100%; + height: 2px; + content: ''; + background-color: transparent; + border-radius: 0; + transform: translate(50%, -50%); +} + +.UnderlineItem[aria-current]:not([aria-current='false']) [data-component='text'], +.UnderlineItem[aria-selected='true'] [data-component='text'] { + font-weight: var(--base-text-weight-semibold); +} + +.UnderlineItem[aria-current]:not([aria-current='false'])::after, +.UnderlineItem[aria-selected='true']::after { + /* stylelint-disable-next-line primer/colors */ + background-color: var(--underlineNav-borderColor-active, var(--color-primer-border-active, #fd8c73)); + + @media (forced-colors: active) { + /* Support for Window Force Color Mode https://learn.microsoft.com/en-us/fluent-ui/web-components/design-system/high-contrast */ + background-color: LinkText; + } +} + +.LoadingCounter { + display: inline-block; + width: 1.5rem; + height: 1rem; /* 16px */ + background-color: var(--bgColor-neutral-muted); + border-color: var(--borderColor-default); + /* stylelint-disable-next-line primer/borders */ + border-radius: 20px; + animation: loadingCounterKeyFrames 1.2s ease-in-out infinite alternate; +} + +@keyframes loadingCounterKeyFrames { + from { + opacity: 1; + } + + to { + opacity: 0.2; + } +} diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx index 57198833a0e..25af0d0954d 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx @@ -9,153 +9,233 @@ import sx, {type SxProp} from '../../sx' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../../utils/polymorphic' import {defaultSxProp} from '../../utils/defaultSxProp' import {get} from '../../constants' +import {toggleStyledComponent} from '../utils/toggleStyledComponent' + +import classes from './UnderlineTabbedInterface.module.css' +import {useFeatureFlag} from '../../FeatureFlags' +import {clsx} from 'clsx' // The gap between the list items. It is a constant because the gap is used to calculate the possible number of items that can fit in the container. export const GAP = 8 -export const StyledUnderlineWrapper = styled.div` - display: flex; - padding-inline: var(--stack-padding-normal, ${get('space.3')}); - justify-content: flex-start; - align-items: center; - /* make space for the underline */ - min-height: var(--control-xlarge-size, 48px); - /* using a box-shadow instead of a border to accomodate 'overflow-y: hidden' on UnderlinePanels */ - box-shadow: inset 0px -1px var(--borderColor-muted, ${get('colors.border.muted')}); - - ${sx}; -` +const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_team' -export const StyledUnderlineItemList = styled.ul` - display: flex; - list-style: none; - white-space: nowrap; - padding: 0; - margin: 0; - align-items: center; - gap: ${GAP}px; - position: relative; -` +const StyledComponentUnderlineWrapper = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + display: flex; + padding-inline: var(--stack-padding-normal, ${get('space.3')}); + justify-content: flex-start; + align-items: center; + /* make space for the underline */ + min-height: var(--control-xlarge-size, 48px); + /* using a box-shadow instead of a border to accomodate 'overflow-y: hidden' on UnderlinePanels */ + box-shadow: inset 0px -1px var(--borderColor-muted, ${get('colors.border.muted')}); -export const StyledUnderlineItem = styled.div` - /* button resets */ - appearance: none; - background-color: transparent; - border: 0; - cursor: pointer; - font: inherit; - - /* underline tab specific styles */ - position: relative; - display: inline-flex; - color: ${get('colors.fg.default')}; - text-align: center; - text-decoration: none; - line-height: var(--text-body-lineHeight-medium, 1.4285); - border-radius: var(--borderRadius-medium, ${get('radii.2')}); - font-size: var(--text-body-size-medium, ${get('fontSizes.1')}); - padding-inline: var(--control-medium-paddingInline-condensed, ${get('space.2')}); - padding-block: var(--control-medium-paddingBlock, 6px); - align-items: center; - - @media (hover: hover) { - &:hover { - background-color: var(--bgColor-neutral-muted, ${get('colors.neutral.subtle')}); - transition: background 0.12s ease-out; - text-decoration: none; - } - } + ${sx}; + `, +) - &:focus: { - outline: 2px solid transparent; - box-shadow: inset 0 0 0 2px var(--fgColor-accent, ${get('colors.accent.fg')}); +type StyledUnderlineWrapperProps = { + slot?: string + as?: React.ElementType + className?: string +} & SxProp - /* where focus-visible is supported, remove the focus box-shadow */ - &:not(:focus-visible) { - box-shadow: none; - } - } +export const StyledUnderlineWrapper = forwardRef( + ({children, className, ...rest}: PropsWithChildren, forwardedRef) => { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) - &:focus-visible { - outline: 2px solid transparent; - box-shadow: inset 0 0 0 2px var(--fgColor-accent, ${get('colors.accent.fg')}); - } + if (enabled) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) + }, +) - /* renders a visibly hidden "copy" of the label in bold, reserving box space for when label becomes bold on selected */ - [data-content]::before { - content: attr(data-content); - display: block; - height: 0; - font-weight: var(--base-text-weight-semibold, ${get('fontWeights.semibold')}); - visibility: hidden; +const StyledComponentUnderlineItemList = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'ul', + styled.ul` + display: flex; + list-style: none; white-space: nowrap; - } - - [data-component='icon'] { - color: var(--fgColor-muted, ${get('colors.fg.muted')}); + padding: 0; + margin: 0; align-items: center; - display: inline-flex; - margin-inline-end: var(--control-medium-gap, ${get('space.2')}); - } + gap: ${GAP}px; + position: relative; + `, +) - [data-component='counter'] { - margin-inline-start: var(--control-medium-gap, ${get('space.2')}); - display: flex; - align-items: center; +export const StyledUnderlineItemList = forwardRef(({children, ...rest}: PropsWithChildren, forwardedRef) => { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + + if (enabled) { + return ( + + {children} + + ) } - /* selected state styles */ - &::after { - position: absolute; - right: 50%; - /* TODO: see if we can simplify this positioning */ - /* 48px total height / 2 (24px) + 1px */ - bottom: calc(50% - calc(var(--control-xlarge-size, 48px) / 2 + 1px)); - width: 100%; - height: 2px; - content: ''; + return ( + + {children} + + ) +}) as PolymorphicForwardRefComponent<'ul'> + +export const StyledUnderlineItem = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + /* button resets */ + appearance: none; background-color: transparent; - border-radius: 0; - transform: translate(50%, -50%); - } + border: 0; + cursor: pointer; + font: inherit; + + /* underline tab specific styles */ + position: relative; + display: inline-flex; + color: ${get('colors.fg.default')}; + text-align: center; + text-decoration: none; + line-height: var(--text-body-lineHeight-medium, 1.4285); + border-radius: var(--borderRadius-medium, ${get('radii.2')}); + font-size: var(--text-body-size-medium, ${get('fontSizes.1')}); + padding-inline: var(--control-medium-paddingInline-condensed, ${get('space.2')}); + padding-block: var(--control-medium-paddingBlock, 6px); + align-items: center; - &[aria-current]:not([aria-current='false']), - &[aria-selected='true'] { - [data-component='text'] { + @media (hover: hover) { + &:hover { + background-color: var(--bgColor-neutral-muted, ${get('colors.neutral.subtle')}); + transition: background 0.12s ease-out; + text-decoration: none; + } + } + + &:focus: { + outline: 2px solid transparent; + box-shadow: inset 0 0 0 2px var(--fgColor-accent, ${get('colors.accent.fg')}); + + /* where focus-visible is supported, remove the focus box-shadow */ + &:not(:focus-visible) { + box-shadow: none; + } + } + + &:focus-visible { + outline: 2px solid transparent; + box-shadow: inset 0 0 0 2px var(--fgColor-accent, ${get('colors.accent.fg')}); + } + + /* renders a visibly hidden "copy" of the label in bold, reserving box space for when label becomes bold on selected */ + [data-content]::before { + content: attr(data-content); + display: block; + height: 0; font-weight: var(--base-text-weight-semibold, ${get('fontWeights.semibold')}); + visibility: hidden; + white-space: nowrap; + } + + [data-component='icon'] { + color: var(--fgColor-muted, ${get('colors.fg.muted')}); + align-items: center; + display: inline-flex; + margin-inline-end: var(--control-medium-gap, ${get('space.2')}); } + [data-component='counter'] { + margin-inline-start: var(--control-medium-gap, ${get('space.2')}); + display: flex; + align-items: center; + } + + /* selected state styles */ &::after { - background-color: var(--underlineNav-borderColor-active, var(--color-primer-border-active, #fd8c73)); + position: absolute; + right: 50%; + /* TODO: see if we can simplify this positioning */ + /* 48px total height / 2 (24px) + 1px */ + bottom: calc(50% - calc(var(--control-xlarge-size, 48px) / 2 + 1px)); + width: 100%; + height: 2px; + content: ''; + background-color: transparent; + border-radius: 0; + transform: translate(50%, -50%); } - } - @media (forced-colors: active) { &[aria-current]:not([aria-current='false']), &[aria-selected='true'] { - ::after { - // Support for Window Force Color Mode https://learn.microsoft.com/en-us/fluent-ui/web-components/design-system/high-contrast - background-color: LinkText; + [data-component='text'] { + font-weight: var(--base-text-weight-semibold, ${get('fontWeights.semibold')}); + } + + &::after { + background-color: var(--underlineNav-borderColor-active, var(--color-primer-border-active, #fd8c73)); } } - } - ${sx}; -` + + @media (forced-colors: active) { + &[aria-current]:not([aria-current='false']), + &[aria-selected='true'] { + ::after { + // Support for Window Force Color Mode https://learn.microsoft.com/en-us/fluent-ui/web-components/design-system/high-contrast + background-color: LinkText; + } + } + } + ${sx}; + `, +) const loadingKeyframes = keyframes` from { opacity: 1; } to { opacity: 0.2; } ` -export const LoadingCounter = styled.span` - animation: ${loadingKeyframes} 1.2s ease-in-out infinite alternate; - background-color: var(--bgColor-neutral-muted, ${get('colors.neutral.subtle')}); - border-color: var(--borderColor-default, ${get('colors.border.default')}); - width: 1.5rem; - height: 1rem; /*16px*/ - display: inline-block; - border-radius: 20px; -` +export const StyledComponentLoadingCounter = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'span', + styled.span` + animation: ${loadingKeyframes} 1.2s ease-in-out infinite alternate; + background-color: var(--bgColor-neutral-muted, ${get('colors.neutral.subtle')}); + border-color: var(--borderColor-default, ${get('colors.border.default')}); + width: 1.5rem; + height: 1rem; /*16px*/ + display: inline-block; + border-radius: 20px; + `, +) + +export const LoadingCounter = () => { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + + if (enabled) { + return + } + + return +} // We can uncomment these when/if we add overflow behavior // to the UnderlinePanels component @@ -212,6 +292,32 @@ export const UnderlineItem = forwardRef( }: PropsWithChildren, forwardedRef, ) => { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + + if (enabled) { + return ( + + {iconsVisible && Icon && {isElement(Icon) ? Icon : }} + {children && ( + + {children} + + )} + {counter !== undefined ? ( + loadingCounters ? ( + + + + ) : ( + + {counter} + + ) + ) : null} + + ) + } + return ( {iconsVisible && Icon && {isElement(Icon) ? Icon : }}