From 1c944ccf2b7e6a61368cbc1fe18b5d33c19a8742 Mon Sep 17 00:00:00 2001 From: exromany Date: Sat, 17 Oct 2026 15:44:29 +0300 Subject: [PATCH 1/4] feat(app-ui): add SkipLink --- packages/lido-app-ui/src/index.ts | 1 + .../src/skip-link/SkipLink.module.css | 31 ++++++ .../src/skip-link/SkipLink.stories.tsx | 55 +++++++++++ .../src/skip-link/SkipLink.test.tsx | 94 +++++++++++++++++++ .../lido-app-ui/src/skip-link/SkipLink.tsx | 64 +++++++++++++ .../lido-app-ui/src/skip-link/constants.ts | 1 + packages/lido-app-ui/src/skip-link/index.ts | 2 + packages/lido-app-ui/src/smoke.test.tsx | 2 + 8 files changed, 250 insertions(+) create mode 100644 packages/lido-app-ui/src/skip-link/SkipLink.module.css create mode 100644 packages/lido-app-ui/src/skip-link/SkipLink.stories.tsx create mode 100644 packages/lido-app-ui/src/skip-link/SkipLink.test.tsx create mode 100644 packages/lido-app-ui/src/skip-link/SkipLink.tsx create mode 100644 packages/lido-app-ui/src/skip-link/constants.ts create mode 100644 packages/lido-app-ui/src/skip-link/index.ts diff --git a/packages/lido-app-ui/src/index.ts b/packages/lido-app-ui/src/index.ts index b8ac81489..6bb72a1c0 100644 --- a/packages/lido-app-ui/src/index.ts +++ b/packages/lido-app-ui/src/index.ts @@ -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' diff --git a/packages/lido-app-ui/src/skip-link/SkipLink.module.css b/packages/lido-app-ui/src/skip-link/SkipLink.module.css new file mode 100644 index 000000000..c03260445 --- /dev/null +++ b/packages/lido-app-ui/src/skip-link/SkipLink.module.css @@ -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; + } +} diff --git a/packages/lido-app-ui/src/skip-link/SkipLink.stories.tsx b/packages/lido-app-ui/src/skip-link/SkipLink.stories.tsx new file mode 100644 index 000000000..fe62acf58 --- /dev/null +++ b/packages/lido-app-ui/src/skip-link/SkipLink.stories.tsx @@ -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 = { + 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 `
` that id.', + }, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Basic: Story = { + render: (args) => ( + <> + + +
+ + Press Tab, then Enter: focus lands here. + +
+ + ), + 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 `
`.', + }, + }, + }, +} diff --git a/packages/lido-app-ui/src/skip-link/SkipLink.test.tsx b/packages/lido-app-ui/src/skip-link/SkipLink.test.tsx new file mode 100644 index 000000000..ef13d7d8d --- /dev/null +++ b/packages/lido-app-ui/src/skip-link/SkipLink.test.tsx @@ -0,0 +1,94 @@ +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() + 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( + <> + +
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( + <> + + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */} +
+ Content +
+ , + ) + fireEvent.click(screen.getByRole('link')) + const content = screen.getByText('Content') + expect(content.getAttribute('tabindex')).toBe('0') + expect(document.activeElement).toBe(content) + }) + + it('resolves encoded ids', () => { + render( + <> + +
Main
+ , + ) + fireEvent.click(screen.getByRole('link')) + expect(document.activeElement).toBe(screen.getByText('Main')) + }) + + it('falls back to native behaviour when the target is missing', () => { + render() + const notPrevented = fireEvent.click(screen.getByRole('link')) + expect(notPrevented).toBe(true) + }) + + it('does not throw on malformed encoded hrefs', () => { + render() + const notPrevented = fireEvent.click(screen.getByRole('link')) + expect(notPrevented).toBe(true) + }) + + it('forwards ref to the anchor', () => { + const ref = createRef() + render() + 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( + <> + +
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(Skip to navigation) + expect( + screen.getByRole('link', { name: 'Skip to navigation' }).className, + ).toContain('x') + }) +}) diff --git a/packages/lido-app-ui/src/skip-link/SkipLink.tsx b/packages/lido-app-ui/src/skip-link/SkipLink.tsx new file mode 100644 index 000000000..e4078e1cd --- /dev/null +++ b/packages/lido-app-ui/src/skip-link/SkipLink.tsx @@ -0,0 +1,64 @@ +import { forwardRef, type AnchorHTMLAttributes, type MouseEvent } from 'react' + +import cn from 'classnames' + +import { MAIN_CONTENT_ID } from './constants' +import styles from './SkipLink.module.css' + +export type SkipLinkProps = Omit< + AnchorHTMLAttributes, + 'href' +> & { + /** Fragment of the element to move focus to. */ + href?: `#${string}` +} + +const findTarget = (fragment: string) => { + const raw = document.getElementById(fragment) + if (raw) return raw + try { + return document.getElementById(decodeURIComponent(fragment)) + } catch { + return null + } +} + +/** Keyboard shortcut past the navigation; hidden until focused. Render it first in the layout. */ +export const SkipLink = forwardRef( + ( + { + href = `#${MAIN_CONTENT_ID}`, + children = 'Skip to content', + className, + onClick, + ...rest + }, + ref, + ) => { + const handleClick = (event: MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + const target = findTarget(href.slice(1)) + if (!target) return + // Safari skips focus on non-focusable targets; hash change can trigger SPA routers. + event.preventDefault() + if (!target.hasAttribute('tabindex')) + target.setAttribute('tabindex', '-1') + target.focus() + } + + return ( + + {children} + + ) + }, +) + +SkipLink.displayName = 'SkipLink' diff --git a/packages/lido-app-ui/src/skip-link/constants.ts b/packages/lido-app-ui/src/skip-link/constants.ts new file mode 100644 index 000000000..d4c8c483f --- /dev/null +++ b/packages/lido-app-ui/src/skip-link/constants.ts @@ -0,0 +1 @@ +export const MAIN_CONTENT_ID = 'main-content' diff --git a/packages/lido-app-ui/src/skip-link/index.ts b/packages/lido-app-ui/src/skip-link/index.ts new file mode 100644 index 000000000..e5d8796e1 --- /dev/null +++ b/packages/lido-app-ui/src/skip-link/index.ts @@ -0,0 +1,2 @@ +export { SkipLink } from './SkipLink' +export type { SkipLinkProps } from './SkipLink' diff --git a/packages/lido-app-ui/src/smoke.test.tsx b/packages/lido-app-ui/src/smoke.test.tsx index e3c144fe6..ee93907cc 100644 --- a/packages/lido-app-ui/src/smoke.test.tsx +++ b/packages/lido-app-ui/src/smoke.test.tsx @@ -55,6 +55,7 @@ import { SidebarToggle, } from './sidebar' import { Skeleton } from './skeleton' +import { SkipLink } from './skip-link' import { Slider } from './slider' import { SmallCard } from './small-card' import { Stack } from './stack' @@ -240,6 +241,7 @@ const cases: Array<[string, () => JSX.Element]> = [ ], ['SidebarToggle', () => ], ['Skeleton', () => ], + ['SkipLink', () => ], ['Slider', () => ], [ 'SmallCard', From f0ed54c2977b61a894a4383213e49996f7e0015e Mon Sep 17 00:00:00 2001 From: exromany Date: Mon, 19 Oct 2026 18:32:03 +0300 Subject: [PATCH 2/4] feat(app-ui): add Main Page landmark carrying SkipLink's default target id; grows so Footer stays at the bottom. --- .../src/page-layout/Main.module.css | 12 ++++++ .../lido-app-ui/src/page-layout/Main.test.tsx | 37 +++++++++++++++++++ packages/lido-app-ui/src/page-layout/Main.tsx | 18 +++++++++ packages/lido-app-ui/src/page-layout/index.ts | 2 + .../lido-app-ui/src/skip-link/SkipLink.tsx | 2 +- .../lido-app-ui/src/skip-link/constants.ts | 1 - packages/lido-app-ui/src/smoke.test.tsx | 3 +- 7 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 packages/lido-app-ui/src/page-layout/Main.module.css create mode 100644 packages/lido-app-ui/src/page-layout/Main.test.tsx create mode 100644 packages/lido-app-ui/src/page-layout/Main.tsx delete mode 100644 packages/lido-app-ui/src/skip-link/constants.ts diff --git a/packages/lido-app-ui/src/page-layout/Main.module.css b/packages/lido-app-ui/src/page-layout/Main.module.css new file mode 100644 index 000000000..84b982d13 --- /dev/null +++ b/packages/lido-app-ui/src/page-layout/Main.module.css @@ -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; +} diff --git a/packages/lido-app-ui/src/page-layout/Main.test.tsx b/packages/lido-app-ui/src/page-layout/Main.test.tsx new file mode 100644 index 000000000..6459dcbbc --- /dev/null +++ b/packages/lido-app-ui/src/page-layout/Main.test.tsx @@ -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(
Content
) + expect(screen.getByRole('main').id).toBe('main-content') + }) + + it('allows overriding the id and forwards ref and className', () => { + const ref = createRef() + render( +
+ Content +
, + ) + 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( + <> + +
Content
+ , + ) + fireEvent.click(screen.getByRole('link')) + expect(document.activeElement).toBe(screen.getByRole('main')) + }) +}) diff --git a/packages/lido-app-ui/src/page-layout/Main.tsx b/packages/lido-app-ui/src/page-layout/Main.tsx new file mode 100644 index 000000000..1a6c2ebd3 --- /dev/null +++ b/packages/lido-app-ui/src/page-layout/Main.tsx @@ -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( + ({ id = MAIN_CONTENT_ID, className, ...rest }, ref) => ( +
+ ), +) + +Main.displayName = 'Main' diff --git a/packages/lido-app-ui/src/page-layout/index.ts b/packages/lido-app-ui/src/page-layout/index.ts index 82b392207..832fe31bd 100644 --- a/packages/lido-app-ui/src/page-layout/index.ts +++ b/packages/lido-app-ui/src/page-layout/index.ts @@ -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' diff --git a/packages/lido-app-ui/src/skip-link/SkipLink.tsx b/packages/lido-app-ui/src/skip-link/SkipLink.tsx index e4078e1cd..4b425c9ec 100644 --- a/packages/lido-app-ui/src/skip-link/SkipLink.tsx +++ b/packages/lido-app-ui/src/skip-link/SkipLink.tsx @@ -2,7 +2,7 @@ import { forwardRef, type AnchorHTMLAttributes, type MouseEvent } from 'react' import cn from 'classnames' -import { MAIN_CONTENT_ID } from './constants' +import { MAIN_CONTENT_ID } from '../page-layout/Main' import styles from './SkipLink.module.css' export type SkipLinkProps = Omit< diff --git a/packages/lido-app-ui/src/skip-link/constants.ts b/packages/lido-app-ui/src/skip-link/constants.ts deleted file mode 100644 index d4c8c483f..000000000 --- a/packages/lido-app-ui/src/skip-link/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const MAIN_CONTENT_ID = 'main-content' diff --git a/packages/lido-app-ui/src/smoke.test.tsx b/packages/lido-app-ui/src/smoke.test.tsx index ee93907cc..566aacd3b 100644 --- a/packages/lido-app-ui/src/smoke.test.tsx +++ b/packages/lido-app-ui/src/smoke.test.tsx @@ -34,7 +34,7 @@ import { MessageBox } from './message-box' import { Modal, ModalCard } from './modal' import { NavLink } from './nav-link' import { Notification } from './notification' -import { Layout, PageContent } from './page-layout' +import { Layout, Main, PageContent } from './page-layout' import { Pagination } from './pagination' import { Popover } from './popover' import { RadioButton } from './radio-button' @@ -166,6 +166,7 @@ const cases: Array<[string, () => JSX.Element]> = [ ), ], ['Logo', () => ], + ['Main', () =>
Content
], ['MessageBox', () => Careful], ['Modal', () => body], ['ModalCard', () => body], From 8b9aa8f1a8d6f6c965c4099111a907a8142f9155 Mon Sep 17 00:00:00 2001 From: exromany Date: Sat, 24 Oct 2026 19:14:21 +0300 Subject: [PATCH 3/4] docs(app-ui): add Main story --- .../src/page-layout/Main.stories.tsx | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 packages/lido-app-ui/src/page-layout/Main.stories.tsx diff --git a/packages/lido-app-ui/src/page-layout/Main.stories.tsx b/packages/lido-app-ui/src/page-layout/Main.stories.tsx new file mode 100644 index 000000000..3b6f37c3e --- /dev/null +++ b/packages/lido-app-ui/src/page-layout/Main.stories.tsx @@ -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 = { + title: 'Layout/Main', + component: Main, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + controls: { disable: true }, + docs: { + story: { inline: false, height: '480px' }, + description: { + component: + 'Page `
` 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 + +const FooterLinks = () => ( + + Terms of Use + +) + +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: () => ( + + + +
+ + Short page content + +
+
+ +
+
+ ), +} + +export const CustomId: Story = { + parameters: { + docs: { + description: { + story: + 'Override `id` and point `SkipLink` at it with a matching `href`.', + }, + }, + }, + render: () => ( + + +
+ + Main with id="content" + +
+
+ +
+
+ ), +} From 93e873992d3164abd12c465af2f8b4e51a13cc27 Mon Sep 17 00:00:00 2001 From: exromany Date: Wed, 28 Oct 2026 13:29:10 +0300 Subject: [PATCH 4/4] fix(app-ui): keep tab order of focusable SkipLink targets --- .../lido-app-ui/src/skip-link/SkipLink.test.tsx | 13 +++++++++++++ packages/lido-app-ui/src/skip-link/SkipLink.tsx | 2 ++ 2 files changed, 15 insertions(+) diff --git a/packages/lido-app-ui/src/skip-link/SkipLink.test.tsx b/packages/lido-app-ui/src/skip-link/SkipLink.test.tsx index ef13d7d8d..ae9899fac 100644 --- a/packages/lido-app-ui/src/skip-link/SkipLink.test.tsx +++ b/packages/lido-app-ui/src/skip-link/SkipLink.test.tsx @@ -41,6 +41,19 @@ describe('SkipLink', () => { expect(document.activeElement).toBe(content) }) + it('does not add tabindex to naturally focusable targets', () => { + render( + <> + + + , + ) + 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( <> diff --git a/packages/lido-app-ui/src/skip-link/SkipLink.tsx b/packages/lido-app-ui/src/skip-link/SkipLink.tsx index 4b425c9ec..e8197cbf4 100644 --- a/packages/lido-app-ui/src/skip-link/SkipLink.tsx +++ b/packages/lido-app-ui/src/skip-link/SkipLink.tsx @@ -42,6 +42,8 @@ export const SkipLink = forwardRef( if (!target) return // Safari skips focus on non-focusable targets; hash change can trigger SPA routers. event.preventDefault() + target.focus() + if (document.activeElement === target) return if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1') target.focus()