Skip to content

Commit 44f66cb

Browse files
1573 accessibility request primer react link enforce what as can be set to with a runtime validation rule 1572 (#2789)
* fix(Link): updated tests and revised logic to accomodate react-router Co-authored-by: Cole Bemis <colebemis@users.noreply.github.com> * chore: added changeset and updated pkgs * added package.json * not sure whats going on with packages on this branch * trying again to fix packages * chore(Link): whoops, fix variable name * chore: get package lock in sync * chore: get package lock in sync * chore: adding changeset again * chore: updated snapshots and revised story wording * Delete gold-boats-mix.md not needed Co-authored-by: Cole Bemis <colebemis@users.noreply.github.com>
1 parent 9cefb95 commit 44f66cb

6 files changed

Lines changed: 109 additions & 27 deletions

File tree

.changeset/tidy-hornets-act.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+
warn user if link `as` prop is not <a> or <button>

src/Link.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import React, {forwardRef, useEffect} from 'react'
12
import styled from 'styled-components'
23
import {system} from 'styled-system'
34
import {get} from './constants'
5+
import {useRefObjectAsForwardedRef} from './hooks'
46
import sx, {SxProp} from './sx'
57
import {ComponentProps} from './utils/types'
8+
import {ForwardRefComponent as PolymorphicForwardRefComponent} from './utils/polymorphic'
69

710
type StyledLinkProps = {
811
hoverColor?: string
@@ -17,7 +20,7 @@ const hoverColor = system({
1720
},
1821
})
1922

20-
const Link = styled.a<StyledLinkProps>`
23+
const StyledLink = styled.a<StyledLinkProps>`
2124
color: ${props => (props.muted ? get('colors.fg.muted')(props) : get('colors.accent.fg')(props))};
2225
text-decoration: ${props => (props.underline ? 'underline' : 'none')};
2326
&:hover {
@@ -38,5 +41,45 @@ const Link = styled.a<StyledLinkProps>`
3841
${sx};
3942
`
4043

44+
const Link = forwardRef(({as: Component = 'a', ...props}, forwardedRef) => {
45+
const innerRef = React.useRef<HTMLAnchorElement>(null)
46+
useRefObjectAsForwardedRef(forwardedRef, innerRef)
47+
48+
if (__DEV__) {
49+
/**
50+
* The Linter yells because it thinks this conditionally calls an effect,
51+
* but since this is a compile-time flag and not a runtime conditional
52+
* this is safe, and ensures the entire effect is kept out of prod builds
53+
* shaving precious bytes from the output, and avoiding mounting a noop effect
54+
*/
55+
// eslint-disable-next-line react-hooks/rules-of-hooks
56+
useEffect(() => {
57+
if (
58+
innerRef.current &&
59+
!(innerRef.current instanceof HTMLButtonElement) &&
60+
!(innerRef.current instanceof HTMLAnchorElement)
61+
) {
62+
// eslint-disable-next-line no-console
63+
console.error(
64+
'Error: Found `Link` component that renders an inaccessible element',
65+
innerRef.current,
66+
'Please ensure `Link` always renders as <a> or <button>',
67+
)
68+
}
69+
}, [innerRef])
70+
}
71+
72+
return (
73+
<StyledLink
74+
as={Component}
75+
{...props}
76+
// @ts-ignore shh
77+
ref={innerRef}
78+
/>
79+
)
80+
}) as PolymorphicForwardRefComponent<'a', StyledLinkProps>
81+
82+
Link.displayName = 'Link'
83+
4184
export type LinkProps = ComponentProps<typeof Link>
4285
export default Link

src/PageHeader/PageHeader.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {useResponsiveValue, ResponsiveValue} from '../hooks/useResponsiveValue'
44
import {SxProp, merge, BetterSystemStyleObject} from '../sx'
55
import Heading from '../Heading'
66
import {ArrowLeftIcon} from '@primer/octicons-react'
7-
import Link from '../Link'
7+
import Link, {LinkProps as BaseLinkProps} from '../Link'
8+
89
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
910
import {getBreakpointDeclarations} from '../utils/getBreakpointDeclarations'
1011
const REGION_ORDER = {
@@ -92,8 +93,8 @@ const ContextArea: React.FC<React.PropsWithChildren<PageHeaderProps>> = ({
9293
return <Box sx={merge<BetterSystemStyleObject>(contentNavStyles, sx)}>{children}</Box>
9394
}
9495
type LinkProps = Pick<
95-
React.AnchorHTMLAttributes<HTMLAnchorElement>,
96-
'download' | 'href' | 'hrefLang' | 'media' | 'ping' | 'rel' | 'target' | 'type' | 'referrerPolicy'
96+
React.AnchorHTMLAttributes<HTMLAnchorElement> & BaseLinkProps,
97+
'download' | 'href' | 'hrefLang' | 'media' | 'ping' | 'rel' | 'target' | 'type' | 'referrerPolicy' | 'as'
9798
>
9899
export type ParentLinkProps = React.PropsWithChildren<PageHeaderProps & LinkProps>
99100

src/__tests__/Link.test.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ describe('Link', () => {
2929

3030
it('respects the "sx" prop', () => {
3131
expect(render(<Link sx={{fontStyle: 'italic'}} />)).toHaveStyleRule('font-style', 'italic')
32-
expect(render(<Link as="i" sx={{fontStyle: 'normal'}} />)).toHaveStyleRule('font-style', 'normal')
3332
})
3433

3534
it('applies button styles when rendering a button element', () => {
@@ -43,4 +42,13 @@ describe('Link', () => {
4342
it('respects the "sx" prop when "muted" prop is also passed', () => {
4443
expect(render(<Link muted sx={{color: 'fg.onEmphasis'}} />)).toMatchSnapshot()
4544
})
45+
46+
it('logs a warning when trying to render invalid "as" prop', () => {
47+
const consoleSpy = jest.spyOn(global.console, 'error').mockImplementation()
48+
49+
HTMLRender(<Link as="i" />)
50+
expect(consoleSpy).toHaveBeenCalled()
51+
52+
consoleSpy.mockRestore()
53+
})
4654
})

src/__tests__/__snapshots__/SideNav.test.tsx.snap

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,6 @@ exports[`SideNav SideNav.Link renders consistently 1`] = `
55
color: #0969da;
66
-webkit-text-decoration: none;
77
text-decoration: none;
8-
position: relative;
9-
display: block;
10-
width: 100%;
11-
text-align: left;
12-
font-size: 14px;
138
}
149
1510
.c0:hover {
@@ -34,29 +29,37 @@ exports[`SideNav SideNav.Link renders consistently 1`] = `
3429
appearance: none;
3530
}
3631
37-
.c0 > .c1 {
32+
.c1 {
33+
position: relative;
34+
display: block;
35+
width: 100%;
36+
text-align: left;
37+
font-size: 14px;
38+
}
39+
40+
.c1 > .c2 {
3841
border-bottom: none;
3942
}
4043
41-
.c1.variant-normal > .c0 {
44+
.c2.variant-normal > .c1 {
4245
color: #24292f;
4346
padding: 16px;
4447
border: 0;
4548
border-top: 1px solid hsla(210,18%,87%,1);
4649
}
4750
48-
.c1.variant-normal > .c0:first-child {
51+
.c2.variant-normal > .c1:first-child {
4952
border-top: 0;
5053
border-top-right-radius: 6px;
5154
border-top-left-radius: 6px;
5255
}
5356
54-
.c1.variant-normal > .c0:last-child {
57+
.c2.variant-normal > .c1:last-child {
5558
border-bottom-right-radius: 6px;
5659
border-bottom-left-radius: 6px;
5760
}
5861
59-
.c1.variant-normal > .c0::before {
62+
.c2.variant-normal > .c1::before {
6063
position: absolute;
6164
top: 0;
6265
bottom: 0;
@@ -67,57 +70,57 @@ exports[`SideNav SideNav.Link renders consistently 1`] = `
6770
content: '';
6871
}
6972
70-
.c1.variant-normal > .c0:hover {
73+
.c2.variant-normal > .c1:hover {
7174
background-color: rgba(234,238,242,0.5);
7275
-webkit-text-decoration: none;
7376
text-decoration: none;
7477
}
7578
76-
.c1.variant-normal > .c0:focus {
79+
.c2.variant-normal > .c1:focus {
7780
background-color: rgba(234,238,242,0.5);
7881
-webkit-text-decoration: none;
7982
text-decoration: none;
8083
outline: solid 2px #0969da;
8184
z-index: 1;
8285
}
8386
84-
.c1.variant-normal > .c0[aria-current='page'],
85-
.c1.variant-normal > .c0[aria-selected='true'] {
87+
.c2.variant-normal > .c1[aria-current='page'],
88+
.c2.variant-normal > .c1[aria-selected='true'] {
8689
background-color: #ffffff;
8790
}
8891
89-
.c1.variant-normal > .c0[aria-current='page']::before,
90-
.c1.variant-normal > .c0[aria-selected='true']::before {
92+
.c2.variant-normal > .c1[aria-current='page']::before,
93+
.c2.variant-normal > .c1[aria-selected='true']::before {
9194
background-color: #fd8c73;
9295
}
9396
94-
.c1.variant-lightweight > .c0 {
97+
.c2.variant-lightweight > .c1 {
9598
padding: 4px 0;
9699
color: #0969da;
97100
}
98101
99-
.c1.variant-lightweight > .c0:hover {
102+
.c2.variant-lightweight > .c1:hover {
100103
color: #24292f;
101104
-webkit-text-decoration: none;
102105
text-decoration: none;
103106
}
104107
105-
.c1.variant-lightweight > .c0:focus {
108+
.c2.variant-lightweight > .c1:focus {
106109
color: #24292f;
107110
-webkit-text-decoration: none;
108111
text-decoration: none;
109112
outline: solid 1px #0969da;
110113
z-index: 1;
111114
}
112115
113-
.c1.variant-lightweight > .c0[aria-current='page'],
114-
.c1.variant-lightweight > .c0[aria-selected='true'] {
116+
.c2.variant-lightweight > .c1[aria-current='page'],
117+
.c2.variant-lightweight > .c1[aria-selected='true'] {
115118
color: #24292f;
116119
font-weight: 500;
117120
}
118121
119122
<a
120-
className="c0"
123+
className="c0 c1"
121124
/>
122125
`;
123126

src/stories/Link.stories.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Link from '../Link'
2+
import {Meta} from '@storybook/react'
3+
import React from 'react'
4+
import {ThemeProvider} from '..'
5+
6+
const meta: Meta = {
7+
title: 'Components/Link',
8+
component: Link,
9+
decorators: [
10+
(Story: React.ComponentType<React.PropsWithChildren<unknown>>): JSX.Element => (
11+
<ThemeProvider>
12+
<Story />
13+
</ThemeProvider>
14+
),
15+
],
16+
}
17+
export default meta
18+
19+
export function LinkStory(): JSX.Element {
20+
return <Link as="i">{`Link with <i> as prop`}</Link>
21+
}
22+
LinkStory.storyName = 'Link'

0 commit comments

Comments
 (0)