Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/lido-app-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export * from './section-title'
export * from './segmented-control'
export * from './sidebar'
export * from './skeleton'
export * from './skip-link'
export * from './slider'
export * from './small-card'
export * from './stack'
Expand Down
12 changes: 12 additions & 0 deletions packages/lido-app-ui/src/page-layout/Main.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.root {
display: flex;

/* keeps the Footer at the bottom on short pages */
flex: 1;
flex-direction: column;
}

/* focused by SkipLink via tabindex=-1; the ring would outline the whole page */
.root:focus {
outline: none;
}
85 changes: 85 additions & 0 deletions packages/lido-app-ui/src/page-layout/Main.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from '@storybook/react'

import { Card } from '../card'
import { Footer } from '../footer'
import { SkipLink } from '../skip-link'
import { TextLink } from '../text-link'
import { Typography } from '../typography'
import { Layout, Main, PageContent } from '.'

const meta: Meta<typeof Main> = {
title: 'Layout/Main',
component: Main,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
controls: { disable: true },
docs: {
story: { inline: false, height: '480px' },
description: {
component:
'Page `<main>` landmark, `id="main-content"` by default so `SkipLink` can target it. Grows (`flex: 1`) inside `Layout`, keeping `Footer` at the bottom of short pages. Focus moves to it from the skip link without a focus ring.',
},
},
},
}

export default meta
type Story = StoryObj<typeof meta>

const FooterLinks = () => (
<TextLink href='https://lido.fi/terms-of-use' external variant='secondary'>
Terms of Use
</TextLink>
)

export const Basic: Story = {
parameters: {
docs: {
description: {
story:
'Press Tab to reveal the skip link, then Enter: focus lands on `Main`. Content is short, so `Footer` stays at the bottom.',
},
},
},
render: () => (
<Layout>
<SkipLink />
<nav aria-label='Demo' style={{ padding: 24 }}>
<Typography variant='body'>Header placeholder</Typography>
</nav>
<Main>
<PageContent>
<Card>Short page content</Card>
</PageContent>
</Main>
<Footer>
<FooterLinks />
</Footer>
</Layout>
),
}

export const CustomId: Story = {
parameters: {
docs: {
description: {
story:
'Override `id` and point `SkipLink` at it with a matching `href`.',
},
},
},
render: () => (
<Layout>
<SkipLink href='#content' />
<Main id='content'>
<PageContent>
<Card>Main with id=&quot;content&quot;</Card>
</PageContent>
</Main>
<Footer>
<FooterLinks />
</Footer>
</Layout>
),
}
37 changes: 37 additions & 0 deletions packages/lido-app-ui/src/page-layout/Main.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createRef } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'

import { SkipLink } from '../skip-link'
import { Main } from './Main'

describe('Main', () => {
it('renders a main landmark with the default id', () => {
render(<Main>Content</Main>)
expect(screen.getByRole('main').id).toBe('main-content')
})

it('allows overriding the id and forwards ref and className', () => {
const ref = createRef<HTMLElement>()
render(
<Main ref={ref} id='page' className='x'>
Content
</Main>,
)
const main = screen.getByRole('main')
expect(main.id).toBe('page')
expect(main.className).toContain('x')
expect(ref.current).toBe(main)
})

it('receives focus from SkipLink', () => {
render(
<>
<SkipLink />
<Main>Content</Main>
</>,
)
fireEvent.click(screen.getByRole('link'))
expect(document.activeElement).toBe(screen.getByRole('main'))
})
})
18 changes: 18 additions & 0 deletions packages/lido-app-ui/src/page-layout/Main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { forwardRef, type ComponentPropsWithoutRef } from 'react'

import cn from 'classnames'

import styles from './Main.module.css'

export const MAIN_CONTENT_ID = 'main-content'

export type MainProps = ComponentPropsWithoutRef<'main'>

/** Page landmark that SkipLink targets; grows to push Footer down. */
export const Main = forwardRef<HTMLElement, MainProps>(
({ id = MAIN_CONTENT_ID, className, ...rest }, ref) => (
<main ref={ref} id={id} className={cn(styles.root, className)} {...rest} />
),
)

Main.displayName = 'Main'
2 changes: 2 additions & 0 deletions packages/lido-app-ui/src/page-layout/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export { Layout } from './Layout'
export type { LayoutProps } from './Layout'
export { Main } from './Main'
export type { MainProps } from './Main'
export { PageContent } from './PageContent'
export type { PageContentProps, PageContentVariant } from './PageContent'
31 changes: 31 additions & 0 deletions packages/lido-app-ui/src/skip-link/SkipLink.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.root {
@mixin font-body-semibold;

position: fixed;
top: 12px;
left: 12px;
z-index: 1200;
padding: 8px 16px;
border-radius: var(--lido-app-ui-border-radius-badge);
background-color: var(--lido-app-ui-color-background-surface);
box-shadow: var(--lido-app-ui-shadow-popover);
color: var(--lido-app-ui-color-text-default);
text-decoration: none;
transform: translateY(calc(-100% - 24px));
transition: transform 0.2s ease;
}

.root:focus {
transform: none;
}

.root:focus-visible {
outline: 2px solid var(--lido-app-ui-color-border-active);
outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
.root {
transition: none;
}
}
55 changes: 55 additions & 0 deletions packages/lido-app-ui/src/skip-link/SkipLink.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect, userEvent, within } from '@storybook/test'

import { Typography } from '../typography'
import { SkipLink } from '.'

const meta: Meta<typeof SkipLink> = {
title: 'Navigation/SkipLink',
component: SkipLink,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'First focusable element of the page; appears on Tab and moves focus to `href` (default `#main-content`). Give your `<main>` that id.',
},
},
},
}

export default meta
type Story = StoryObj<typeof meta>

export const Basic: Story = {
render: (args) => (
<>
<SkipLink {...args} />
<nav aria-label='Demo' style={{ padding: 24 }}>
<Typography variant='body'>Navigation…</Typography>
</nav>
<main id='main-content' style={{ padding: 24 }}>
<Typography variant='body'>
Press Tab, then Enter: focus lands here.
</Typography>
</main>
</>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.tab()
const link = canvas.getByRole('link', { name: 'Skip to content' })
await expect(link).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect(canvas.getByRole('main')).toHaveFocus()
},
parameters: {
docs: {
description: {
story:
'The play function tabs to the link and activates it; focus moves to `<main>`.',
},
},
},
}
107 changes: 107 additions & 0 deletions packages/lido-app-ui/src/skip-link/SkipLink.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { createRef } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'

import { SkipLink } from './SkipLink'

describe('SkipLink', () => {
it('renders default text and target', () => {
render(<SkipLink />)
const link = screen.getByRole('link', { name: 'Skip to content' })
expect(link.getAttribute('href')).toBe('#main-content')
})

it('focuses the target and makes it focusable', () => {
render(
<>
<SkipLink />
<main id='main-content'>Main</main>
</>,
)
const main = screen.getByRole('main')
const notPrevented = fireEvent.click(screen.getByRole('link'))
expect(notPrevented).toBe(false)
expect(main.getAttribute('tabindex')).toBe('-1')
expect(document.activeElement).toBe(main)
})

it('keeps an existing tabindex', () => {
render(
<>
<SkipLink href='#content' />
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div id='content' tabIndex={0}>
Content
</div>
</>,
)
fireEvent.click(screen.getByRole('link'))
const content = screen.getByText('Content')
expect(content.getAttribute('tabindex')).toBe('0')
expect(document.activeElement).toBe(content)
})

it('does not add tabindex to naturally focusable targets', () => {
render(
<>
<SkipLink href='#field' />
<input id='field' aria-label='Field' />
</>,
)
fireEvent.click(screen.getByRole('link'))
const input = screen.getByLabelText('Field')
expect(input.hasAttribute('tabindex')).toBe(false)
expect(document.activeElement).toBe(input)
})

it('resolves encoded ids', () => {
render(
<>
<SkipLink href='#main%20content' />
<div id='main content'>Main</div>
</>,
)
fireEvent.click(screen.getByRole('link'))
expect(document.activeElement).toBe(screen.getByText('Main'))
})

it('falls back to native behaviour when the target is missing', () => {
render(<SkipLink href='#nowhere' />)
const notPrevented = fireEvent.click(screen.getByRole('link'))
expect(notPrevented).toBe(true)
})

it('does not throw on malformed encoded hrefs', () => {
render(<SkipLink href='#50%off' />)
const notPrevented = fireEvent.click(screen.getByRole('link'))
expect(notPrevented).toBe(true)
})

it('forwards ref to the anchor', () => {
const ref = createRef<HTMLAnchorElement>()
render(<SkipLink ref={ref} />)
expect(ref.current).toBe(screen.getByRole('link'))
})

it('runs consumer onClick and respects its preventDefault', () => {
const onClick = vi.fn((event: { preventDefault: () => void }) =>
event.preventDefault(),
)
render(
<>
<SkipLink onClick={onClick} />
<main id='main-content'>Main</main>
</>,
)
fireEvent.click(screen.getByRole('link'))
expect(onClick).toHaveBeenCalledTimes(1)
expect(document.activeElement).not.toBe(screen.getByRole('main'))
})

it('merges className and custom children', () => {
render(<SkipLink className='x'>Skip to navigation</SkipLink>)
expect(
screen.getByRole('link', { name: 'Skip to navigation' }).className,
).toContain('x')
})
})
Loading
Loading