Skip to content

Commit 4534b9d

Browse files
authored
[Chakra V3 Upgrade 🎨] Create Skip Nav Components (@W-18507873@) (#2757)
* init * Clean up styles * PR Feedback * Use zIndex value * Use zIndex default skipNav value * PR Feedback * Fix copyright year * Use token values instead of px * Move styles to base
1 parent e68fdbd commit 4534b9d

File tree

6 files changed

+309
-28
lines changed

6 files changed

+309
-28
lines changed

jest.config.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ module.exports = {
4040
createTestGlob('components/swatch-group'),
4141
createTestGlob('components/toaster'),
4242
createTestGlob('components/list-menu'),
43+
createTestGlob('components/skip-nav'),
4344
createTestGlob('components/login'),
4445
createTestGlob('components/register'),
4546
createTestGlob('components/email-confirmation'),
@@ -97,10 +98,6 @@ module.exports = {
9798
'<rootDir>/node_modules/@chakra-ui/react/dist/cjs/$1/index.cjs',
9899
'<rootDir>/node_modules/@chakra-ui/react/dist/cjs/index.cjs'
99100
],
100-
'^@chakra-ui/skip-nav/(.*)$': [
101-
'<rootDir>/node_modules/@chakra-ui/skip-nav/dist/index.js',
102-
'<rootDir>/node_modules/@chakra-ui/skip-nav/dist/$1.js'
103-
],
104101
'^proxy-compare$': '<rootDir>/node_modules/proxy-compare/dist/cjs/index.js',
105102
'^uqr$': '<rootDir>/node_modules/uqr/dist/index.cjs',
106103
// handle pwa-kit extensibility special import
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import React from 'react'
9+
import {screen} from '@testing-library/react'
10+
import {SkipNavLink, SkipNavContent} from './index'
11+
import {renderWithProviders} from '../../utils/test-utils'
12+
13+
describe('SkipNavLink', () => {
14+
test('renders with default props', () => {
15+
renderWithProviders(<SkipNavLink>Skip to Content</SkipNavLink>, {})
16+
17+
const link = screen.getByRole('link', {name: 'Skip to Content'})
18+
expect(link).toBeInTheDocument()
19+
expect(link).toHaveAttribute('href', '#skip-to-content')
20+
})
21+
22+
test('renders with custom href', () => {
23+
renderWithProviders(<SkipNavLink href="#main-content">Skip to Main</SkipNavLink>, {})
24+
25+
const link = screen.getByRole('link', {name: 'Skip to Main'})
26+
expect(link).toHaveAttribute('href', '#main-content')
27+
})
28+
29+
test('has correct accessibility attributes', () => {
30+
renderWithProviders(<SkipNavLink>Skip to Content</SkipNavLink>, {})
31+
32+
const link = screen.getByRole('link', {name: 'Skip to Content'})
33+
expect(link).toHaveAttribute('href', '#skip-to-content')
34+
})
35+
36+
test('has visually hidden styles by default', () => {
37+
renderWithProviders(<SkipNavLink>Skip to Content</SkipNavLink>, {})
38+
39+
const link = screen.getByRole('link', {name: 'Skip to Content'})
40+
41+
// The link should be focusable (no aria-hidden) but positioned off-screen
42+
expect(link).not.toHaveAttribute('aria-hidden')
43+
expect(link).toHaveAttribute('href', '#skip-to-content')
44+
})
45+
46+
test('uses theme styles', () => {
47+
renderWithProviders(<SkipNavLink>Skip to Content</SkipNavLink>, {})
48+
49+
const link = screen.getByRole('link', {name: 'Skip to Content'})
50+
expect(link).toBeInTheDocument()
51+
// The component should use theme styles from the skipNav slot recipe
52+
expect(link).toHaveAttribute('href', '#skip-to-content')
53+
})
54+
})
55+
56+
describe('SkipNavContent', () => {
57+
test('renders with default props', () => {
58+
renderWithProviders(
59+
<SkipNavContent>
60+
<div>Main content</div>
61+
</SkipNavContent>,
62+
{}
63+
)
64+
65+
const content = screen.getByText('Main content')
66+
const container = content.parentElement
67+
68+
expect(container).toHaveAttribute('id', 'skip-to-content')
69+
expect(container).toHaveAttribute('tabIndex', '-1')
70+
})
71+
72+
test('renders with custom id', () => {
73+
renderWithProviders(
74+
<SkipNavContent id="main-content">
75+
<div>Main content</div>
76+
</SkipNavContent>,
77+
{}
78+
)
79+
80+
const content = screen.getByText('Main content')
81+
const container = content.parentElement
82+
83+
expect(container).toHaveAttribute('id', 'main-content')
84+
expect(container).toHaveAttribute('tabIndex', '-1')
85+
})
86+
87+
test('has correct accessibility attributes', () => {
88+
renderWithProviders(
89+
<SkipNavContent>
90+
<div>Main content</div>
91+
</SkipNavContent>,
92+
{}
93+
)
94+
95+
const content = screen.getByText('Main content')
96+
const container = content.parentElement
97+
98+
expect(container).toHaveAttribute('id', 'skip-to-content')
99+
expect(container).toHaveAttribute('tabIndex', '-1')
100+
})
101+
102+
test('uses theme styles', () => {
103+
renderWithProviders(
104+
<SkipNavContent>
105+
<div>Content with theme</div>
106+
</SkipNavContent>,
107+
{}
108+
)
109+
110+
const content = screen.getByText('Content with theme')
111+
const container = content.parentElement
112+
113+
expect(container).toBeInTheDocument()
114+
// The component should use theme styles from the skipNav slot recipe
115+
expect(container).toHaveAttribute('id', 'skip-to-content')
116+
})
117+
})
118+
119+
describe('SkipNavLink and SkipNavContent integration', () => {
120+
test('link href matches content id', () => {
121+
renderWithProviders(
122+
<div>
123+
<SkipNavLink>Skip to Content</SkipNavLink>
124+
<SkipNavContent>
125+
<div>Main content</div>
126+
</SkipNavContent>
127+
</div>,
128+
{}
129+
)
130+
131+
const link = screen.getByRole('link', {name: 'Skip to Content'})
132+
const content = screen.getByText('Main content')
133+
const container = content.parentElement
134+
135+
expect(link).toHaveAttribute('href', '#skip-to-content')
136+
expect(container).toHaveAttribute('id', 'skip-to-content')
137+
})
138+
139+
test('custom href and id work together', () => {
140+
renderWithProviders(
141+
<div>
142+
<SkipNavLink href="#main">Skip to Main</SkipNavLink>
143+
<SkipNavContent id="main">
144+
<div>Main content</div>
145+
</SkipNavContent>
146+
</div>,
147+
{}
148+
)
149+
150+
const link = screen.getByRole('link', {name: 'Skip to Main'})
151+
const content = screen.getByText('Main content')
152+
const container = content.parentElement
153+
154+
expect(link).toHaveAttribute('href', '#main')
155+
expect(container).toHaveAttribute('id', 'main')
156+
})
157+
158+
test('both components use skip-nav theme styles', () => {
159+
renderWithProviders(
160+
<div>
161+
<SkipNavLink>Skip to Content</SkipNavLink>
162+
<SkipNavContent>
163+
<div>Main content</div>
164+
</SkipNavContent>
165+
</div>,
166+
{}
167+
)
168+
169+
const link = screen.getByRole('link', {name: 'Skip to Content'})
170+
const content = screen.getByText('Main content')
171+
const container = content.parentElement
172+
173+
// Both components should be rendered and use theme styles
174+
expect(link).toBeInTheDocument()
175+
expect(container).toBeInTheDocument()
176+
expect(link).toHaveAttribute('href', '#skip-to-content')
177+
expect(container).toHaveAttribute('id', 'skip-to-content')
178+
})
179+
})

src/components/skip-nav/index.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import React from 'react'
9+
import {Box, Link, useSlotRecipe} from '@chakra-ui/react'
10+
11+
interface SkipNavLinkProps {
12+
children: any
13+
href?: string
14+
[key: string]: any
15+
}
16+
17+
interface SkipNavContentProps {
18+
children: any
19+
css?: any
20+
id?: string
21+
[key: string]: any
22+
}
23+
24+
/**
25+
* SkipNavLink component provides a skip link for keyboard navigation
26+
* with initial state screen reader accessible but visually hidden
27+
*/
28+
export const SkipNavLink = ({children, href = '#skip-to-content', ...props}: SkipNavLinkProps) => {
29+
const recipe = useSlotRecipe({key: 'skipNav'})
30+
const styles = recipe()
31+
32+
return (
33+
<Link href={href} css={styles.link} {...props}>
34+
{children}
35+
</Link>
36+
)
37+
}
38+
39+
/**
40+
* SkipNavContent component provides the target content area for skip navigation
41+
*/
42+
export const SkipNavContent = ({
43+
children,
44+
css,
45+
id = 'skip-to-content',
46+
...props
47+
}: SkipNavContentProps) => {
48+
const recipe = useSlotRecipe({key: 'skipNav'})
49+
const styles = recipe()
50+
51+
return (
52+
<Box id={id} css={{...styles.content, ...css}} tabIndex={-1} {...props}>
53+
{children}
54+
</Box>
55+
)
56+
}

src/components/with-layout/with-layout.tsx

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232

3333
// Local Project Components
3434
import {DrawerMenu} from '../drawer-menu'
35+
import {SkipNavLink, SkipNavContent} from '../skip-nav'
3536
import {getPathWithLocale} from '../../utils/url'
3637
import {HideOnDesktop, HideOnMobile} from '../responsive'
3738
import {ListMenu, ListMenuContent} from '../list-menu'
@@ -279,8 +280,7 @@ const withLayout = <P extends object>(WrappedComponent: React.ComponentType<P>)
279280

280281
<ScrollToTop />
281282
<Box id="app" display="flex" flexDirection="column" flex={1}>
282-
{/*TODO: recreating this component because @chakra-ui/skip-nav does not have V3 version*/}
283-
{/*<SkipNavLink zIndex="skipLink">Skip to Content</SkipNavLink>*/}
283+
<SkipNavLink>Skip to Content</SkipNavLink>
284284
<Box css={styles.headerWrapper}>
285285
{!isCheckout ? (
286286
<>
@@ -321,28 +321,27 @@ const withLayout = <P extends object>(WrappedComponent: React.ComponentType<P>)
321321
</Box>
322322
{!isOnline && <OfflineBanner />}
323323
<AddToCartModalProvider>
324-
{/*TODO: recreating this component because @chakra-ui/skip-nav does not have V3 version*/}
325-
{/*<SkipNavContent*/}
326-
{/* style={{*/}
327-
{/* display: 'flex',*/}
328-
{/* flexDirection: 'column',*/}
329-
{/* flex: 1,*/}
330-
{/* outline: 0*/}
331-
{/* }}*/}
332-
{/*>*/}
333-
<Box
334-
as="main"
335-
id="app-main"
336-
role="main"
337-
display="flex"
338-
flexDirection="column"
339-
flex="1"
324+
<SkipNavContent
325+
css={{
326+
display: 'flex',
327+
flexDirection: 'column',
328+
flex: 1,
329+
outline: 0
330+
}}
340331
>
341-
<OfflineBoundary isOnline={isOnline}>
342-
<WrappedComponent {...(props as P)} />
343-
</OfflineBoundary>
344-
</Box>
345-
{/*</SkipNavContent>*/}
332+
<Box
333+
as="main"
334+
id="app-main"
335+
role="main"
336+
display="flex"
337+
flexDirection="column"
338+
flex="1"
339+
>
340+
<OfflineBoundary isOnline={isOnline}>
341+
<WrappedComponent {...(props as P)} />
342+
</OfflineBoundary>
343+
</Box>
344+
</SkipNavContent>
346345

347346
{!isCheckout ? <Footer /> : <CheckoutFooter />}
348347
<AuthModal {...(authModal as any)} />
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import {defineSlotRecipe} from '@chakra-ui/react'
8+
9+
const focusStyles = {
10+
// !important needed to override the default hidden positioning (absolute + left: -10000px)
11+
position: 'fixed !important',
12+
top: '1.5 !important',
13+
left: '1.5 !important',
14+
width: 'auto !important',
15+
height: 'auto !important',
16+
overflow: 'visible !important',
17+
zIndex: 'skipNav',
18+
padding: 2,
19+
backgroundColor: 'white',
20+
color: 'black',
21+
textDecoration: 'none',
22+
border: '2px solid black',
23+
borderRadius: 'md',
24+
fontSize: 'sm',
25+
fontWeight: 'bold',
26+
outline: 'none',
27+
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
28+
whiteSpace: 'nowrap'
29+
}
30+
31+
export default defineSlotRecipe({
32+
slots: ['link', 'content'],
33+
base: {
34+
link: {
35+
position: 'absolute',
36+
zIndex: 'skipNav',
37+
left: '-10000px',
38+
top: 'auto',
39+
width: '1px',
40+
height: '1px',
41+
overflow: 'hidden',
42+
_focusVisible: focusStyles,
43+
_focus: focusStyles
44+
},
45+
content: {}
46+
}
47+
})

src/theme/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import quantityPicker from './components/project/quantity-picker'
5353
import search from './components/project/search'
5454
import socialIcons from './components/project/social-icons'
5555
import swatchGroup from './components/project/swatch-group'
56+
import skipNav from './components/project/skip-nav'
5657

5758
// Please refer to the Chakra-Ui theme customization docs found
5859
// here https://chakra-ui.com/docs/theming/customize-theme to learn
@@ -81,6 +82,7 @@ export const overrides = defineConfig({
8182
body: `-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`,
8283
mono: `SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace`
8384
},
85+
8486
breakpoints
8587
},
8688
semanticTokens: {
@@ -128,7 +130,8 @@ export const overrides = defineConfig({
128130
quantityPicker,
129131
socialIcons,
130132
swatchGroup,
131-
search
133+
search,
134+
skipNav
132135
}
133136
// keep these here for reference til we finish the components
134137
// components: {

0 commit comments

Comments
 (0)