Skip to content

Commit 161e3fd

Browse files
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
1 parent 5a8138a commit 161e3fd

File tree

6 files changed

+440
-128
lines changed

6 files changed

+440
-128
lines changed

.changeset/slimy-chefs-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
Convert UnderlinePanels to CSS modules behind feature flags
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.StyledUnderlineWrapper {
2+
width: 100%;
3+
overflow-x: auto;
4+
overflow-y: hidden;
5+
-webkit-overflow-scrolling: auto;
6+
}
7+
8+
.StyledUnderlineWrapper[data-icons-visible='false'] [data-component='icon'] {
9+
display: none;
10+
}

packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, {Children, isValidElement, cloneElement, useState, useRef, type FC, type PropsWithChildren} from 'react'
22
import {TabContainerElement} from '@github/tab-container-element'
3+
import type {IconProps} from '@primer/octicons-react'
34
import {createComponent} from '../../utils/create-component'
45
import {
56
StyledUnderlineItemList,
@@ -10,11 +11,15 @@ import {
1011
import Box, {type BoxProps} from '../../Box'
1112
import {useId} from '../../hooks'
1213
import {invariant} from '../../utils/invariant'
13-
import type {IconProps} from '@primer/octicons-react'
1414
import {merge, type BetterSystemStyleObject, type SxProp} from '../../sx'
1515
import {defaultSxProp} from '../../utils/defaultSxProp'
1616
import {useResizeObserver, type ResizeObserverEntry} from '../../hooks/useResizeObserver'
1717
import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect'
18+
import {useFeatureFlag} from '../../FeatureFlags'
19+
import classes from './UnderlinePanels.module.css'
20+
import {toggleStyledComponent} from '../../internal/utils/toggleStyledComponent'
21+
22+
const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_team'
1823

1924
export type UnderlinePanelsProps = {
2025
/**
@@ -59,6 +64,12 @@ export type PanelProps = Omit<BoxProps, 'as'>
5964

6065
const TabContainerComponent = createComponent(TabContainerElement, 'tab-container')
6166

67+
const StyledTabContainerComponent = toggleStyledComponent(
68+
CSS_MODULES_FEATURE_FLAG,
69+
'tab-container',
70+
TabContainerComponent,
71+
)
72+
6273
const UnderlinePanels: FC<UnderlinePanelsProps> = ({
6374
'aria-label': ariaLabel,
6475
'aria-labelledby': ariaLabelledBy,
@@ -102,6 +113,8 @@ const UnderlinePanels: FC<UnderlinePanelsProps> = ({
102113
)
103114
const tabsHaveIcons = tabs.current.some(tab => React.isValidElement(tab) && tab.props.icon)
104115

116+
const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG)
117+
105118
// this is a workaround to get the list's width on the first render
106119
const [listWidth, setListWidth] = useState(0)
107120
useIsomorphicLayoutEffect(() => {
@@ -114,15 +127,19 @@ const UnderlinePanels: FC<UnderlinePanelsProps> = ({
114127

115128
// when the wrapper resizes, check if the icons should be visible
116129
// by comparing the wrapper width to the list width
117-
useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => {
118-
if (!tabsHaveIcons) {
119-
return
120-
}
121-
122-
const wrapperWidth = resizeObserverEntries[0].contentRect.width
123-
124-
setIconsVisible(wrapperWidth > listWidth)
125-
}, wrapperRef)
130+
useResizeObserver(
131+
(resizeObserverEntries: ResizeObserverEntry[]) => {
132+
if (!tabsHaveIcons) {
133+
return
134+
}
135+
136+
const wrapperWidth = resizeObserverEntries[0].contentRect.width
137+
138+
setIconsVisible(wrapperWidth > listWidth)
139+
},
140+
wrapperRef,
141+
[enabled],
142+
)
126143

127144
if (__DEV__) {
128145
// only one tab can be selected at a time
@@ -141,8 +158,28 @@ const UnderlinePanels: FC<UnderlinePanelsProps> = ({
141158
)
142159
}
143160

161+
if (enabled) {
162+
return (
163+
<StyledTabContainerComponent>
164+
<StyledUnderlineWrapper
165+
ref={wrapperRef}
166+
slot="tablist-wrapper"
167+
data-icons-visible={iconsVisible}
168+
sx={sxProp}
169+
className={classes.StyledUnderlineWrapper}
170+
{...props}
171+
>
172+
<StyledUnderlineItemList ref={listRef} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} role="tablist">
173+
{tabs.current}
174+
</StyledUnderlineItemList>
175+
</StyledUnderlineWrapper>
176+
{tabPanels.current}
177+
</StyledTabContainerComponent>
178+
)
179+
}
180+
144181
return (
145-
<TabContainerComponent>
182+
<StyledTabContainerComponent>
146183
<StyledUnderlineWrapper
147184
ref={wrapperRef}
148185
slot="tablist-wrapper"
@@ -166,7 +203,7 @@ const UnderlinePanels: FC<UnderlinePanelsProps> = ({
166203
</StyledUnderlineItemList>
167204
</StyledUnderlineWrapper>
168205
{tabPanels.current}
169-
</TabContainerComponent>
206+
</StyledTabContainerComponent>
170207
)
171208
}
172209

packages/react/src/hooks/useResizeObserver.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ export interface ResizeObserverEntry {
99
contentRect: DOMRectReadOnly
1010
}
1111

12-
export function useResizeObserver<T extends HTMLElement>(callback: ResizeObserverCallback, target?: RefObject<T>) {
12+
export function useResizeObserver<T extends HTMLElement>(
13+
callback: ResizeObserverCallback,
14+
target?: RefObject<T>,
15+
depsArray: unknown[] = [],
16+
) {
1317
const savedCallback = useRef(callback)
1418

1519
useLayoutEffect(() => {
@@ -31,5 +35,6 @@ export function useResizeObserver<T extends HTMLElement>(callback: ResizeObserve
3135
return () => {
3236
observer.disconnect()
3337
}
34-
}, [target])
38+
// eslint-disable-next-line react-hooks/exhaustive-deps
39+
}, [target, ...depsArray])
3540
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
.UnderlineWrapper {
2+
display: flex;
3+
/* stylelint-disable-next-line primer/spacing */
4+
padding-inline: var(--stack-padding-normal);
5+
justify-content: flex-start;
6+
align-items: center;
7+
8+
/* make space for the underline */
9+
min-height: var(--control-xlarge-size, 48px);
10+
11+
/* using a box-shadow instead of a border to accomodate 'overflow-y: hidden' on UnderlinePanels */
12+
/* stylelint-disable-next-line primer/box-shadow */
13+
box-shadow: inset 0 -1px var(--borderColor-muted);
14+
}
15+
16+
.UnderlineItemList {
17+
position: relative;
18+
display: flex;
19+
padding: 0;
20+
margin: 0;
21+
white-space: nowrap;
22+
list-style: none;
23+
align-items: center;
24+
gap: 8px;
25+
}
26+
27+
.UnderlineItem {
28+
/* underline tab specific styles */
29+
position: relative;
30+
display: inline-flex;
31+
font: inherit;
32+
font-size: var(--text-body-size-medium);
33+
line-height: var(--text-body-lineHeight-medium, 1.4285);
34+
color: var(--fgColor-default);
35+
text-align: center;
36+
text-decoration: none;
37+
cursor: pointer;
38+
background-color: transparent;
39+
border: 0;
40+
border-radius: var(--borderRadius-medium, var(--borderRadius-small));
41+
42+
/* button resets */
43+
appearance: none;
44+
padding-inline: var(--base-size-8);
45+
padding-block: var(--base-size-6);
46+
align-items: center;
47+
48+
@media (hover: hover) {
49+
&:hover {
50+
text-decoration: none;
51+
background-color: var(--bgColor-neutral-muted);
52+
transition: background-color 0.12s ease-out;
53+
}
54+
}
55+
}
56+
57+
.UnderlineItem:focus {
58+
outline: 2px solid transparent;
59+
/* stylelint-disable-next-line primer/box-shadow */
60+
box-shadow: inset 0 0 0 2px var(--fgColor-accent);
61+
62+
/* where focus-visible is supported, remove the focus box-shadow */
63+
&:not(:focus-visible) {
64+
box-shadow: none;
65+
}
66+
}
67+
68+
.UnderlineItem:focus-visible {
69+
outline: 2px solid transparent;
70+
/* stylelint-disable-next-line primer/box-shadow */
71+
box-shadow: inset 0 0 0 2px var(--fgColor-accent);
72+
}
73+
74+
/* renders a visibly hidden "copy" of the label in bold, reserving box space for when label becomes bold on selected */
75+
.UnderlineItem [data-content]::before {
76+
display: block;
77+
height: 0;
78+
font-weight: var(--base-text-weight-semibold);
79+
white-space: nowrap;
80+
visibility: hidden;
81+
content: attr(data-content);
82+
}
83+
84+
.UnderlineItem [data-component='icon'] {
85+
display: inline-flex;
86+
color: var(--fgColor-muted);
87+
align-items: center;
88+
margin-inline-end: var(--base-size-8);
89+
}
90+
91+
.UnderlineItem [data-component='counter'] {
92+
margin-inline-start: var(--base-size-8);
93+
display: flex;
94+
align-items: center;
95+
}
96+
97+
.UnderlineItem::after {
98+
position: absolute;
99+
right: 50%;
100+
101+
/* TODO: see if we can simplify this positioning */
102+
103+
/* 48px total height / 2 (24px) + 1px */
104+
/* stylelint-disable-next-line primer/spacing */
105+
bottom: calc(50% - calc(var(--control-xlarge-size, var(--base-size-48)) / 2 + 1px));
106+
width: 100%;
107+
height: 2px;
108+
content: '';
109+
background-color: transparent;
110+
border-radius: 0;
111+
transform: translate(50%, -50%);
112+
}
113+
114+
.UnderlineItem[aria-current]:not([aria-current='false']) [data-component='text'],
115+
.UnderlineItem[aria-selected='true'] [data-component='text'] {
116+
font-weight: var(--base-text-weight-semibold);
117+
}
118+
119+
.UnderlineItem[aria-current]:not([aria-current='false'])::after,
120+
.UnderlineItem[aria-selected='true']::after {
121+
/* stylelint-disable-next-line primer/colors */
122+
background-color: var(--underlineNav-borderColor-active, var(--color-primer-border-active, #fd8c73));
123+
124+
@media (forced-colors: active) {
125+
/* Support for Window Force Color Mode https://learn.microsoft.com/en-us/fluent-ui/web-components/design-system/high-contrast */
126+
background-color: LinkText;
127+
}
128+
}
129+
130+
.LoadingCounter {
131+
display: inline-block;
132+
width: 1.5rem;
133+
height: 1rem; /* 16px */
134+
background-color: var(--bgColor-neutral-muted);
135+
border-color: var(--borderColor-default);
136+
/* stylelint-disable-next-line primer/borders */
137+
border-radius: 20px;
138+
animation: loadingCounterKeyFrames 1.2s ease-in-out infinite alternate;
139+
}
140+
141+
@keyframes loadingCounterKeyFrames {
142+
from {
143+
opacity: 1;
144+
}
145+
146+
to {
147+
opacity: 0.2;
148+
}
149+
}

0 commit comments

Comments
 (0)