Skip to content

Commit 8d9a357

Browse files
joshblackjonrohan
andauthored
refactor(BranchName): update BranchName to use CSS Modules (#5040)
* refactor(BranchName): update BranchName to use CSS Modules * chore: add changeset * fix: use sx instead of defaultSxProp * chore: remove defaultSxProp * chore: experiment with forward ref * Remove feature flag duplication * Add test for `className` support in `BranchName` * refactor: update usage for forwardRef * chore: update fallthrough to use as prop * Update BranchName.displayname * test: add option to skip display name check --------- Co-authored-by: Josh Black <joshblack@users.noreply.github.com> Co-authored-by: Jon Rohan <yes@jonrohan.codes>
1 parent 719def7 commit 8d9a357

File tree

7 files changed

+189
-105
lines changed

7 files changed

+189
-105
lines changed

.changeset/four-schools-grin.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+
Update BranchName to use CSS Modules behind feature flag

e2e/components/BranchName.test.ts

Lines changed: 45 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -2,110 +2,58 @@ import {test, expect} from '@playwright/test'
22
import {visit} from '../test-helpers/storybook'
33
import {themes} from '../test-helpers/themes'
44

5-
test.describe('BranchName', () => {
6-
test.describe('Default', () => {
7-
for (const theme of themes) {
8-
test.describe(theme, () => {
9-
test('default @vrt', async ({page}) => {
10-
await visit(page, {
11-
id: 'components-branchname--default',
12-
globals: {
13-
colorScheme: theme,
14-
},
15-
})
16-
17-
// Default state
18-
expect(await page.screenshot()).toMatchSnapshot(`BranchName.Default.${theme}.png`)
19-
20-
// Focus state
21-
await page.keyboard.press('Tab')
22-
expect(await page.screenshot()).toMatchSnapshot(`BranchName.Default.${theme}.focus.png`)
23-
})
5+
const stories = [
6+
{
7+
title: 'Default',
8+
id: 'components-branchname--default',
9+
focus: true,
10+
},
11+
{
12+
title: 'Not A Link',
13+
id: 'components-branchname-features--not-a-link',
14+
focus: false,
15+
},
16+
{
17+
title: 'With A Branch Icon',
18+
id: 'components-branchname-features--with-branch-icon',
19+
focus: false,
20+
},
21+
] as const
2422

25-
test('axe @aat', async ({page}) => {
26-
await visit(page, {
27-
id: 'components-branchname--default',
28-
globals: {
29-
colorScheme: theme,
30-
},
31-
})
32-
await expect(page).toHaveNoViolations({
33-
rules: {
34-
'color-contrast': {
35-
enabled: theme !== 'dark_dimmed',
23+
test.describe('BranchName', () => {
24+
for (const story of stories) {
25+
test.describe(story.title, () => {
26+
for (const theme of themes) {
27+
test.describe(theme, () => {
28+
test('default @vrt', async ({page}) => {
29+
await visit(page, {
30+
id: story.id,
31+
globals: {
32+
colorScheme: theme,
3633
},
37-
},
38-
})
39-
})
40-
})
41-
}
42-
})
43-
44-
test.describe('Not A Link', () => {
45-
for (const theme of themes) {
46-
test.describe(theme, () => {
47-
test('default @vrt', async ({page}) => {
48-
await visit(page, {
49-
id: 'components-branchname-features--not-a-link',
50-
globals: {
51-
colorScheme: theme,
52-
},
53-
})
54-
55-
// Default state
56-
expect(await page.screenshot()).toMatchSnapshot(`BranchName.Not A Link.${theme}.png`)
57-
})
34+
})
5835

59-
test('axe @aat', async ({page}) => {
60-
await visit(page, {
61-
id: 'components-branchname-features--not-a-link',
62-
globals: {
63-
colorScheme: theme,
64-
},
65-
})
66-
await expect(page).toHaveNoViolations({
67-
rules: {
68-
'color-contrast': {
69-
enabled: theme !== 'dark_dimmed',
70-
},
71-
},
72-
})
73-
})
74-
})
75-
}
76-
})
36+
// Default state
37+
expect(await page.screenshot()).toMatchSnapshot(`BranchName.${story.title}.${theme}.png`)
7738

78-
test.describe('With A Branch Icon', () => {
79-
for (const theme of themes) {
80-
test.describe(theme, () => {
81-
test('default @vrt', async ({page}) => {
82-
await visit(page, {
83-
id: 'components-branchname-features--with-branch-icon',
84-
globals: {
85-
colorScheme: theme,
86-
},
39+
// Focus state
40+
if (story.focus) {
41+
await page.keyboard.press('Tab')
42+
expect(await page.screenshot()).toMatchSnapshot(`BranchName.${story.title}.${theme}.focus.png`)
43+
}
8744
})
8845

89-
// Default state
90-
expect(await page.screenshot()).toMatchSnapshot(`BranchName.With A Branch Icon.${theme}.png`)
91-
})
92-
93-
test('axe @aat', async ({page}) => {
94-
await visit(page, {
95-
id: 'components-branchname-features--with-branch-icon',
96-
globals: {
97-
colorScheme: theme,
98-
},
99-
})
100-
await expect(page).toHaveNoViolations({
101-
rules: {
102-
'color-contrast': {
103-
enabled: theme !== 'dark_dimmed',
46+
test('axe @aat', async ({page}) => {
47+
await visit(page, {
48+
id: story.id,
49+
globals: {
50+
colorScheme: theme,
10451
},
105-
},
52+
})
53+
await expect(page).toHaveNoViolations()
10654
})
10755
})
108-
})
109-
}
110-
})
56+
}
57+
})
58+
}
11159
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.BranchName {
2+
display: inline-block;
3+
padding: var(--base-size-2) var(--base-size-6);
4+
font-family: var(--fontStack-monospace);
5+
font-size: var(--text-body-size-small);
6+
color: var(--fgColor-link);
7+
text-decoration: none;
8+
background-color: var(--bgColor-accent-muted);
9+
border-radius: var(--borderRadius-medium);
10+
11+
&:is(:not(a)) {
12+
color: var(--fgColor-muted);
13+
}
14+
}

packages/react/src/BranchName/BranchName.tsx

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import React, {type ForwardedRef} from 'react'
2+
import {clsx} from 'clsx'
13
import styled from 'styled-components'
24
import {get} from '../constants'
35
import type {SxProp} from '../sx'
46
import sx from '../sx'
5-
import type {ComponentProps} from '../utils/types'
7+
import {useFeatureFlag} from '../FeatureFlags'
8+
import Box from '../Box'
9+
import classes from './BranchName.module.css'
610

7-
const BranchName = styled.a<SxProp>`
11+
const StyledBranchName = styled.a<SxProp>`
812
display: inline-block;
913
padding: 2px 6px;
1014
font-size: var(--text-body-size-small, ${get('fontSizes.0')});
@@ -19,5 +23,50 @@ const BranchName = styled.a<SxProp>`
1923
${sx};
2024
`
2125

22-
export type BranchNameProps = ComponentProps<typeof BranchName>
23-
export default BranchName
26+
type BranchNameProps<As extends React.ElementType> = {
27+
as?: As
28+
} & DistributiveOmit<React.ComponentPropsWithRef<React.ElementType extends As ? 'a' : As>, 'as'> &
29+
SxProp
30+
31+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32+
function BranchName<As extends React.ElementType>(props: BranchNameProps<As>, ref: ForwardedRef<any>) {
33+
const {as: BaseComponent = 'a', className, children, sx, ...rest} = props
34+
const enabled = useFeatureFlag('primer_react_css_modules_team')
35+
36+
if (enabled) {
37+
if (sx) {
38+
return (
39+
<Box {...rest} ref={ref} as={BaseComponent} className={clsx(className, classes.BranchName)} sx={sx}>
40+
{children}
41+
</Box>
42+
)
43+
}
44+
45+
return (
46+
<BaseComponent {...rest} ref={ref} className={clsx(className, classes.BranchName)}>
47+
{children}
48+
</BaseComponent>
49+
)
50+
}
51+
52+
return (
53+
<StyledBranchName {...rest} as={BaseComponent} ref={ref} className={className} sx={sx}>
54+
{children}
55+
</StyledBranchName>
56+
)
57+
}
58+
59+
// eslint-disable-next-line @typescript-eslint/ban-types
60+
type FixedForwardRef = <T, P = {}>(
61+
render: (props: P, ref: React.Ref<T>) => React.ReactNode,
62+
) => (props: P & React.RefAttributes<T>) => React.ReactNode
63+
64+
const fixedForwardRef = React.forwardRef as FixedForwardRef
65+
66+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
67+
type DistributiveOmit<T, TOmitted extends PropertyKey> = T extends any ? Omit<T, TOmitted> : never
68+
69+
BranchName.displayName = 'BranchName'
70+
71+
export type {BranchNameProps}
72+
export default fixedForwardRef(BranchName)

packages/react/src/BranchName/__tests__/BranchName.test.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ import BranchName from '../BranchName'
33
import {render, behavesAsComponent, checkExports} from '../../utils/testing'
44
import {render as HTMLRender} from '@testing-library/react'
55
import axe from 'axe-core'
6+
import {FeatureFlags} from '../../FeatureFlags'
67

78
describe('BranchName', () => {
8-
behavesAsComponent({Component: BranchName})
9+
behavesAsComponent({
10+
Component: BranchName,
11+
options: {
12+
skipDisplayName: true,
13+
},
14+
})
915

1016
checkExports('BranchName', {
1117
default: BranchName,
@@ -20,4 +26,23 @@ describe('BranchName', () => {
2026
it('renders an <a> by default', () => {
2127
expect(render(<BranchName />).type).toEqual('a')
2228
})
29+
30+
it('should support `className` on the outermost element', () => {
31+
const Element = () => <BranchName className={'test-class-name'} />
32+
const FeatureFlagElement = () => {
33+
return (
34+
<FeatureFlags
35+
flags={{
36+
primer_react_css_modules_team: true,
37+
primer_react_css_modules_staff: true,
38+
primer_react_css_modules_ga: true,
39+
}}
40+
>
41+
<Element />
42+
</FeatureFlags>
43+
)
44+
}
45+
expect(HTMLRender(<Element />).container.firstChild).toHaveClass('test-class-name')
46+
expect(HTMLRender(<FeatureFlagElement />).container.firstChild).toHaveClass('test-class-name')
47+
})
2348
})

packages/react/src/BranchName/__tests__/BranchName.types.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,43 @@ export function shouldNotAcceptSystemProps() {
99
// @ts-expect-error system props should not be accepted
1010
return <BranchName backgroundColor="thistle" />
1111
}
12+
13+
export function shouldAcceptAs() {
14+
return (
15+
<BranchName
16+
as="button"
17+
onClick={event => {
18+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
19+
type test = Expect<Equal<typeof event, React.MouseEvent<HTMLButtonElement, MouseEvent>>>
20+
}}
21+
/>
22+
)
23+
}
24+
25+
export function defaultAsIsAnchor() {
26+
return (
27+
<BranchName
28+
onClick={event => {
29+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
30+
type test = Expect<Equal<typeof event, React.MouseEvent<HTMLAnchorElement, MouseEvent>>>
31+
}}
32+
/>
33+
)
34+
}
35+
36+
export function ShouldAcceptRef() {
37+
const ref = React.useRef<HTMLButtonElement>(null)
38+
return (
39+
<BranchName
40+
as="button"
41+
ref={ref}
42+
onClick={event => {
43+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
44+
type test = Expect<Equal<typeof event, React.MouseEvent<HTMLButtonElement, MouseEvent>>>
45+
}}
46+
/>
47+
)
48+
}
49+
50+
type Expect<T extends true> = T
51+
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false

packages/react/src/utils/testing.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export function unloadCSS(path: string) {
193193
interface Options {
194194
skipAs?: boolean
195195
skipSx?: boolean
196+
skipDisplayName?: boolean
196197
}
197198

198199
interface BehavesAsComponent {
@@ -221,9 +222,11 @@ export function behavesAsComponent({Component, toRender, options}: BehavesAsComp
221222
})
222223
}
223224

224-
it('sets a valid displayName', () => {
225-
expect(Component.displayName).toMatch(COMPONENT_DISPLAY_NAME_REGEX)
226-
})
225+
if (!options.skipDisplayName) {
226+
it('sets a valid displayName', () => {
227+
expect(Component.displayName).toMatch(COMPONENT_DISPLAY_NAME_REGEX)
228+
})
229+
}
227230
}
228231

229232
// eslint-disable-next-line @typescript-eslint/no-explicit-any

0 commit comments

Comments
 (0)