diff --git a/packages/lido-app-ui/src/button/Button.module.css b/packages/lido-app-ui/src/button/Button.module.css index 35951ab11..031718451 100644 --- a/packages/lido-app-ui/src/button/Button.module.css +++ b/packages/lido-app-ui/src/button/Button.module.css @@ -20,6 +20,7 @@ font-family: var(--lido-app-ui-font-family); font-weight: var(--lido-app-ui-font-weight-semibold); letter-spacing: var(--lido-app-ui-letter-spacing-button); + text-decoration: none; white-space: nowrap; cursor: pointer; transition: @@ -28,7 +29,7 @@ color 0.2s ease; } -.button:disabled:not(.loading) { +.button:is(:disabled, [aria-disabled='true']):not(.loading) { --local-color: var(--lido-app-ui-color-text-disabled); --local-spinner-color: var(--lido-app-ui-color-icons-disabled); @@ -110,11 +111,11 @@ --local-spinner-color: var(--lido-app-ui-color-icons-inverse); } -.primary:not(:disabled):hover { +.primary:not(:disabled, [aria-disabled='true']):hover { --local-bg: var(--lido-app-ui-color-background-action-hover); } -.primary:not(:disabled):active { +.primary:not(:disabled, [aria-disabled='true']):active { --local-bg: var(--lido-app-ui-color-background-action-pressed); } @@ -125,11 +126,11 @@ --local-spinner-color: var(--lido-app-ui-color-icons-secondary); } -.outline:not(:disabled):hover { +.outline:not(:disabled, [aria-disabled='true']):hover { --local-bg: var(--lido-app-ui-color-background-transparent-hover); } -.outline:not(:disabled):active { +.outline:not(:disabled, [aria-disabled='true']):active { --local-bg: var(--lido-app-ui-color-background-transparent-pressed); } @@ -140,12 +141,12 @@ --local-spinner-color: var(--lido-app-ui-color-icons-tertiary); } -.subtle:not(:disabled):hover { +.subtle:not(:disabled, [aria-disabled='true']):hover { --local-bg: var(--lido-app-ui-color-background-transparent-hover); --local-color: var(--lido-app-ui-color-text-default); } -.subtle:not(:disabled):active { +.subtle:not(:disabled, [aria-disabled='true']):active { --local-bg: var(--lido-app-ui-color-background-transparent-pressed); --local-color: var(--lido-app-ui-color-text-default); } @@ -155,11 +156,11 @@ --local-spinner-color: var(--lido-app-ui-color-icons-secondary); } -.ghost:not(:disabled):hover { +.ghost:not(:disabled, [aria-disabled='true']):hover { --local-bg: var(--lido-app-ui-color-background-transparent-hover); } -.ghost:not(:disabled):active { +.ghost:not(:disabled, [aria-disabled='true']):active { --local-bg: var(--lido-app-ui-color-background-transparent-pressed); } @@ -170,23 +171,23 @@ --local-spinner-color: var(--lido-app-ui-color-icons-error); } -.error:not(:disabled):hover { +.error:not(:disabled, [aria-disabled='true']):hover { --local-bg: var(--lido-app-ui-color-tint-error-hover); } -.error:not(:disabled):active { +.error:not(:disabled, [aria-disabled='true']):active { --local-bg: var(--lido-app-ui-color-tint-error); } /* Disabled look; plain loading keeps the variant's default look. */ -.primary:disabled:not(.loading) { +.primary:is(:disabled, [aria-disabled='true']):not(.loading) { --local-bg: var(--lido-app-ui-color-background-disabled); } -.outline:disabled:not(.loading), -.subtle:disabled:not(.loading), -.error:disabled:not(.loading) { +.outline:is(:disabled, [aria-disabled='true']):not(.loading), +.subtle:is(:disabled, [aria-disabled='true']):not(.loading), +.error:is(:disabled, [aria-disabled='true']):not(.loading) { --local-bg: transparent; --local-border-color: var(--lido-app-ui-color-border-default); } diff --git a/packages/lido-app-ui/src/button/Button.stories.tsx b/packages/lido-app-ui/src/button/Button.stories.tsx index d1b8d224a..995a89a7a 100644 --- a/packages/lido-app-ui/src/button/Button.stories.tsx +++ b/packages/lido-app-ui/src/button/Button.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react' -import { Button, type ButtonVariant } from '.' +import { Button, LinkButton, type ButtonVariant } from '.' import { StoryContainer, StorySection } from '../../.storybook/components' import { Counter, type CounterVariant } from '../counter' @@ -8,12 +8,11 @@ import { IconInfo } from '../icons' const meta: Meta = { title: 'Buttons/Button', component: Button, - tags: ['autodocs'], args: { children: 'Button', }, argTypes: { - children: { description: 'Button label', control: { type: 'text' } }, + children: { control: { type: 'text' } }, variant: { control: { type: 'select' }, options: ['primary', 'outline', 'subtle', 'ghost', 'error'], @@ -26,15 +25,6 @@ const meta: Meta = { loading: { control: { type: 'boolean' } }, type: { table: { disable: true } }, }, - parameters: { - controls: { exclude: ['type'] }, - docs: { - description: { - component: - 'Fully rounded call to action. `variant` picks the fill/border treatment, `size` picks big (52px) or small (36px), `loading` swaps the icon slot for a spinner and disables the button.', - }, - }, - }, } export default meta @@ -166,7 +156,7 @@ export const WithCounter: Story = { variant={variant} size={size} counter={3} - aria-label={`${label}, 3 unread`} + counterLabel='3 unread' {...state} > {label} @@ -181,7 +171,7 @@ export const WithCounter: Story = { key={counterVariant} variant='outline' counter={3} - aria-label={`${counterVariant}, 3 unread`} + counterLabel='3 unread' > {counterVariant} @@ -194,7 +184,37 @@ export const WithCounter: Story = { docs: { description: { story: - 'Corner `counter` slot, typically a `Counter`. It is aria-hidden, so include the count in `aria-label`.', + 'Corner `counter` slot, typically a `Counter`. It is aria-hidden; `counterLabel` adds the count to the accessible name.', + }, + }, + }, +} + +export const AsLink: Story = { + render: () => ( + + + {variants.map((variant) => ( + + {variant} + + ))} + + + {variants.map((variant) => ( + + {variant} + + ))} + + + ), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + '`LinkButton` is the same button as an ``, for navigation: it keeps link semantics and routes through `LinkProvider`. `disabled` drops the `href` and sets `aria-disabled`. It takes `external` and the anchor attributes instead of `type` and `loading`.', }, }, }, diff --git a/packages/lido-app-ui/src/button/Button.test.tsx b/packages/lido-app-ui/src/button/Button.test.tsx index e039ee331..9d70e9437 100644 --- a/packages/lido-app-ui/src/button/Button.test.tsx +++ b/packages/lido-app-ui/src/button/Button.test.tsx @@ -3,6 +3,7 @@ import { createRef } from 'react' import { describe, expect, it } from 'vitest' import { Counter } from '../counter' +import counterStyles from '../counter/Counter.module.css' import { Button } from './Button' describe('Button', () => { @@ -45,6 +46,38 @@ describe('Button', () => { }) }) +describe('Button loading', () => { + it('disables, marks busy and hides icons while loading', () => { + render( + , + ) + const button = screen.getByRole('button') + expect(button).toBeDisabled() + expect(button).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByTestId('left')).not.toBeInTheDocument() + expect(screen.queryByTestId('right')).not.toBeInTheDocument() + }) + + it('shows icons when not loading', () => { + render( + , + ) + expect(screen.getByTestId('left')).toBeInTheDocument() + expect(screen.getByTestId('right')).toBeInTheDocument() + }) +}) + describe('Button counter', () => { it('renders the counter inside an aria-hidden wrapper', () => { render( @@ -56,13 +89,13 @@ describe('Button counter', () => { , ) const wrapper = screen.getByTestId('counter') - expect(wrapper.getAttribute('aria-hidden')).toBe('true') + expect(wrapper).toHaveAttribute('aria-hidden', 'true') expect(wrapper.textContent).toBe('5') }) it('renders no counter by default', () => { render() - expect(screen.queryByTestId('counter')).toBeNull() + expect(screen.queryByTestId('counter')).not.toBeInTheDocument() }) it('renders no wrapper for counter={false}', () => { @@ -71,6 +104,50 @@ describe('Button counter', () => { Inbox , ) - expect(screen.queryByTestId('counter')).toBeNull() + expect(screen.queryByTestId('counter')).not.toBeInTheDocument() + }) + + it('wraps a numeric counter in a default Counter', () => { + render( + , + ) + const pill = screen.getByTestId('counter').firstElementChild + expect(pill).toHaveTextContent('3') + expect(pill).toHaveClass(counterStyles.counter) + }) + + it('renders a zero counter as a pill', () => { + render( + , + ) + const pill = screen.getByTestId('counter').firstElementChild + expect(pill).toHaveClass(counterStyles.counter) + expect(pill).toHaveTextContent('0') + }) + + it('appends counterLabel to the accessible name', () => { + render( + , + ) + expect( + screen.getByRole('button', { name: 'Inbox 3 unread' }), + ).toBeInTheDocument() + }) + + it('joins aria-label and counterLabel', () => { + render( + , + ) + expect( + screen.getByRole('button', { name: 'Inbox, 3 unread' }), + ).toBeInTheDocument() }) }) diff --git a/packages/lido-app-ui/src/button/Button.tsx b/packages/lido-app-ui/src/button/Button.tsx index 581b0f5a9..bac27c175 100644 --- a/packages/lido-app-ui/src/button/Button.tsx +++ b/packages/lido-app-ui/src/button/Button.tsx @@ -1,34 +1,24 @@ -import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react' +import { forwardRef, type ButtonHTMLAttributes } from 'react' import cn from 'classnames' -import { CounterSlot } from '../counter/CounterSlot' -import { IconLoaderCircle } from '../icons' import styles from './Button.module.css' +import { ButtonContent, joinAriaLabel } from './ButtonContent' +import type { + ButtonDataTestId, + ButtonOwnProps, + ButtonSize, + ButtonVariant, +} from './types' -export type ButtonVariant = 'primary' | 'outline' | 'subtle' | 'ghost' | 'error' +export type { ButtonDataTestId, ButtonSize, ButtonVariant } -export type ButtonSize = 'big' | 'small' - -export type ButtonDataTestId = { - root?: string - iconLeft?: string - iconRight?: string - content?: string - counter?: string -} - -export type ButtonProps = ButtonHTMLAttributes & { - variant?: ButtonVariant - size?: ButtonSize - loading?: boolean - iconLeft?: ReactNode - iconRight?: ReactNode - /** Corner slot, aria-hidden: put the count into `aria-label`. */ - counter?: ReactNode - dataTestId?: ButtonDataTestId -} +export type ButtonProps = ButtonHTMLAttributes & + ButtonOwnProps & { + loading?: boolean + } +/** Fully rounded call to action; `size` is big (52px) or small (36px), `loading` swaps the icon slot for a spinner and disables the button. */ export const Button = forwardRef( ( { @@ -42,7 +32,9 @@ export const Button = forwardRef( iconLeft, iconRight, counter, + counterLabel, dataTestId, + 'aria-label': ariaLabel, ...rest }, ref, @@ -60,34 +52,19 @@ export const Button = forwardRef( className, )} data-testid={dataTestId?.root} + aria-label={joinAriaLabel(ariaLabel, counterLabel)} {...rest} > - {loading ? ( - + ), + ) + Routed.displayName = 'Routed' + render( + + Keys + , + ) + expect(screen.getByRole('link')).toHaveAttribute('data-routed') + }) + + it('drops href and click when disabled', async () => { + const onClick = vi.fn() + render( + + Keys + , + ) + const link = screen.getByRole('link') + expect(link).not.toHaveAttribute('href') + expect(link).toHaveAttribute('aria-disabled', 'true') + await userEvent.click(link) + expect(onClick).not.toHaveBeenCalled() + }) + + it('joins aria-label with counterLabel', () => { + render( + + Inbox + , + ) + expect(screen.getByRole('link')).toHaveAccessibleName('Inbox, 3 unread') + }) +}) diff --git a/packages/lido-app-ui/src/button/LinkButton.tsx b/packages/lido-app-ui/src/button/LinkButton.tsx new file mode 100644 index 000000000..c0f3245ab --- /dev/null +++ b/packages/lido-app-ui/src/button/LinkButton.tsx @@ -0,0 +1,53 @@ +import { forwardRef } from 'react' + +import cn from 'classnames' + +import { Anchor, type AnchorProps } from '../link-provider/Anchor' +import styles from './Button.module.css' +import { ButtonContent, joinAriaLabel } from './ButtonContent' +import type { ButtonOwnProps } from './types' + +export type LinkButtonProps = Omit & + ButtonOwnProps & { + href: string + } + +/** Button-styled `` that routes through `LinkProvider`; `disabled` drops the `href`. */ +export const LinkButton = forwardRef( + ( + { + className, + children, + variant = 'primary', + size = 'big', + iconLeft, + iconRight, + counter, + counterLabel, + dataTestId, + 'aria-label': ariaLabel, + ...rest + }, + ref, + ) => ( + + + {children} + + + ), +) + +LinkButton.displayName = 'LinkButton' diff --git a/packages/lido-app-ui/src/button/index.ts b/packages/lido-app-ui/src/button/index.ts index 22d14215e..e2119f467 100644 --- a/packages/lido-app-ui/src/button/index.ts +++ b/packages/lido-app-ui/src/button/index.ts @@ -5,3 +5,5 @@ export type { ButtonVariant, ButtonDataTestId, } from './Button' +export { LinkButton } from './LinkButton' +export type { LinkButtonProps } from './LinkButton' diff --git a/packages/lido-app-ui/src/button/types.ts b/packages/lido-app-ui/src/button/types.ts new file mode 100644 index 000000000..87a0433d1 --- /dev/null +++ b/packages/lido-app-ui/src/button/types.ts @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react' + +export type ButtonVariant = 'primary' | 'outline' | 'subtle' | 'ghost' | 'error' + +export type ButtonSize = 'big' | 'small' + +export type ButtonDataTestId = { + root?: string + iconLeft?: string + iconRight?: string + content?: string + counter?: string +} + +export type ButtonOwnProps = { + variant?: ButtonVariant + size?: ButtonSize + iconLeft?: ReactNode + iconRight?: ReactNode + /** + * Corner slot, aria-hidden. A number or string renders a default `Counter` + * (`0` shows a "0" pill; use `counter={count || null}` to hide it), or pass a + * `` element. Announce the count with `counterLabel`. + */ + counter?: ReactNode + /** Spoken with the name, e.g. "3 unread". */ + counterLabel?: string + dataTestId?: ButtonDataTestId +} diff --git a/packages/lido-app-ui/src/colors/tokenReferences.test.ts b/packages/lido-app-ui/src/colors/tokenReferences.test.ts index 47fe037e5..139fae2a6 100644 --- a/packages/lido-app-ui/src/colors/tokenReferences.test.ts +++ b/packages/lido-app-ui/src/colors/tokenReferences.test.ts @@ -36,7 +36,6 @@ const privateTokens = [ /^--lido-app-ui-fade-(start|end)$/, /^--lido-app-ui-logo-breakpoint$/, /^--lido-app-ui-(breadcrumbs|header-nav)-fade-(start|end)$/, - /^--lido-app-ui-color-text$/, ] const customMediaPattern = /(? = { title: 'Tags & Badges/Counter', component: Counter, - tags: ['autodocs'], args: { variant: 'default', children: '1', @@ -16,15 +15,6 @@ const meta: Meta = { options: ['default', 'neutral', 'error', 'warning', 'action', 'active'], control: { type: 'select' }, }, - children: { description: 'Number (or short text) to display' }, - }, - parameters: { - docs: { - description: { - component: - 'A 20px pill for notification/unread counts. Grows horizontally for multi-digit values.', - }, - }, }, } diff --git a/packages/lido-app-ui/src/counter/Counter.tsx b/packages/lido-app-ui/src/counter/Counter.tsx index 9ed046c17..cc33b8bef 100644 --- a/packages/lido-app-ui/src/counter/Counter.tsx +++ b/packages/lido-app-ui/src/counter/Counter.tsx @@ -1,4 +1,4 @@ -import type { HTMLAttributes } from 'react' +import { forwardRef, type HTMLAttributes } from 'react' import cn from 'classnames' @@ -21,18 +21,18 @@ export type CounterProps = HTMLAttributes & { dataTestId?: CounterDataTestId } -export const Counter = ({ - variant = 'default', - className, - children, - dataTestId, - ...rest -}: CounterProps) => ( - - {children} - +/** 20px pill for notification/unread counts; grows horizontally for multi-digit values. */ +export const Counter = forwardRef( + ({ variant = 'default', className, children, dataTestId, ...rest }, ref) => ( + + {children} + + ), ) + +Counter.displayName = 'Counter' diff --git a/packages/lido-app-ui/src/counter/CounterSlot.module.css b/packages/lido-app-ui/src/counter/CounterSlot.module.css index f02d5551e..484cb9511 100644 --- a/packages/lido-app-ui/src/counter/CounterSlot.module.css +++ b/packages/lido-app-ui/src/counter/CounterSlot.module.css @@ -2,8 +2,19 @@ /* absolute offsets start inside the border; keep the visual gap constant */ display: flex; position: absolute; - top: calc(-1 * var(--local-counter-offset) - var(--local-border-width, 0px)); + top: calc( + -1 * var(--local-counter-offset, 4px) - var(--local-border-width, 0px) + ); inset-inline-end: calc( - -1 * var(--local-counter-offset) - var(--local-border-width, 0px) + -1 * var(--local-counter-offset, 4px) - var(--local-border-width, 0px) ); } + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/packages/lido-app-ui/src/counter/CounterSlot.tsx b/packages/lido-app-ui/src/counter/CounterSlot.tsx index 3484ce94b..e80420ae2 100644 --- a/packages/lido-app-ui/src/counter/CounterSlot.tsx +++ b/packages/lido-app-ui/src/counter/CounterSlot.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' +import { Counter } from './Counter' import styles from './CounterSlot.module.css' type CounterSlotProps = { @@ -7,9 +8,24 @@ type CounterSlotProps = { dataTestId?: string } -export const CounterSlot = ({ children, dataTestId }: CounterSlotProps) => - children == null || children === false ? null : ( +export const CounterSlot = ({ children, dataTestId }: CounterSlotProps) => { + if (children == null || typeof children === 'boolean' || children === '') { + return null + } + + const content = + typeof children === 'number' || typeof children === 'string' ? ( + {children} + ) : ( + children + ) + + return ( ) +} + +export const CounterLabel = ({ children }: { children?: string }) => + children ? {children} : null diff --git a/packages/lido-app-ui/src/icon-button/IconButton.stories.tsx b/packages/lido-app-ui/src/icon-button/IconButton.stories.tsx index 0686c6a4a..a9df52e9e 100644 --- a/packages/lido-app-ui/src/icon-button/IconButton.stories.tsx +++ b/packages/lido-app-ui/src/icon-button/IconButton.stories.tsx @@ -8,7 +8,6 @@ import { IconBell, IconCross } from '../icons' const meta: Meta = { title: 'Buttons/IconButton', component: IconButton, - tags: ['autodocs'], args: { icon: , 'aria-label': 'Close', @@ -22,15 +21,6 @@ const meta: Meta = { loading: { control: { type: 'boolean' } }, type: { table: { disable: true } }, }, - parameters: { - controls: { exclude: ['type'] }, - docs: { - description: { - component: - '36px square, fully rounded. Icon-only, so `aria-label` is required. `loading` swaps the icon for a spinner and disables the button.', - }, - }, - }, } export default meta @@ -141,7 +131,7 @@ export const States: Story = { docs: { description: { story: - 'Each row is default, loading, disabled, then disabled with loading.', + 'Each row is default, loading, disabled, then disabled with loading. Hover and press a button to see its hover and pressed states.', }, }, }, @@ -166,11 +156,12 @@ export const WithCounter: Story = { icon={} variant='outline' counter={{count}} - aria-label={`Notifications, ${count} unread`} + aria-label='Notifications' + counterLabel={`${count} unread`} /> ))} - {(['outline', 'ghost'] as const).map((variant) => ( + {(['outline', 'surface', 'ghost'] as const).map((variant) => ( {(['default', 'error'] as const).map((counterVariant) => ( @@ -185,7 +176,8 @@ export const WithCounter: Story = { icon={} variant={variant} counter={3} - aria-label='Notifications, 3 unread' + aria-label='Notifications' + counterLabel='3 unread' {...state} /> )), @@ -199,7 +191,8 @@ export const WithCounter: Story = { icon={} variant='outline' counter={3} - aria-label='Notifications, 3 unread' + aria-label='Notifications' + counterLabel='3 unread' /> ))} @@ -210,7 +203,7 @@ export const WithCounter: Story = { docs: { description: { story: - 'Corner `counter` slot, typically a `Counter`. It is aria-hidden, so include the count in `aria-label`.', + 'Corner `counter` slot, typically a `Counter`. It is aria-hidden; `counterLabel` adds the count to the accessible name.', }, }, }, diff --git a/packages/lido-app-ui/src/icon-button/IconButton.test.tsx b/packages/lido-app-ui/src/icon-button/IconButton.test.tsx index a981b620d..cb1a9c309 100644 --- a/packages/lido-app-ui/src/icon-button/IconButton.test.tsx +++ b/packages/lido-app-ui/src/icon-button/IconButton.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { render, screen } from '@testing-library/react' import { Counter } from '../counter' +import counterStyles from '../counter/Counter.module.css' import { IconButton } from './IconButton' describe('IconButton', () => { @@ -16,8 +17,8 @@ describe('IconButton', () => { it('disables and marks busy while loading', () => { render() const button = screen.getByRole('button') - expect(button).toHaveProperty('disabled', true) - expect(button.getAttribute('aria-busy')).toBe('true') + expect(button).toBeDisabled() + expect(button).toHaveAttribute('aria-busy', 'true') }) it('keeps disabled and busy when loading and disabled together', () => { @@ -49,7 +50,7 @@ describe('IconButton counter', () => { />, ) const wrapper = screen.getByTestId('counter') - expect(wrapper.getAttribute('aria-hidden')).toBe('true') + expect(wrapper).toHaveAttribute('aria-hidden', 'true') expect(wrapper.textContent).toBe('5') }) @@ -61,7 +62,7 @@ describe('IconButton counter', () => { dataTestId={{ counter: 'counter' }} />, ) - expect(screen.queryByTestId('counter')).toBeNull() + expect(screen.queryByTestId('counter')).not.toBeInTheDocument() }) it('renders no wrapper for counter={false}', () => { @@ -73,6 +74,34 @@ describe('IconButton counter', () => { dataTestId={{ counter: 'counter' }} />, ) - expect(screen.queryByTestId('counter')).toBeNull() + expect(screen.queryByTestId('counter')).not.toBeInTheDocument() + }) + + it('wraps a numeric counter in a default Counter', () => { + render( + , + ) + const pill = screen.getByTestId('counter').firstElementChild + expect(pill).toHaveClass(counterStyles.counter) + expect(pill).toHaveTextContent('3') + }) + + it('appends counterLabel to aria-label', () => { + render( + , + ) + expect( + screen.getByRole('button', { name: 'Bell, 3 unread' }), + ).toBeInTheDocument() }) }) diff --git a/packages/lido-app-ui/src/icon-button/IconButton.tsx b/packages/lido-app-ui/src/icon-button/IconButton.tsx index 31b4bbd96..d0a4ba72f 100644 --- a/packages/lido-app-ui/src/icon-button/IconButton.tsx +++ b/packages/lido-app-ui/src/icon-button/IconButton.tsx @@ -23,11 +23,18 @@ export type IconButtonProps = Omit< icon: ReactNode variant?: IconButtonVariant loading?: boolean - /** Corner slot, aria-hidden: put the count into `aria-label`. */ + /** + * Corner slot, aria-hidden. A number or string renders a default `Counter` + * (`0` shows a "0" pill; use `counter={count || null}` to hide it), or pass a + * `` element. Announce the count with `counterLabel`. + */ counter?: ReactNode + /** Spoken with the name, e.g. "3 unread". */ + counterLabel?: string dataTestId?: IconButtonDataTestId } +/** 36px fully rounded icon-only button; `loading` swaps the icon for a spinner and disables it. */ export const IconButton = forwardRef( ( { @@ -38,6 +45,8 @@ export const IconButton = forwardRef( disabled = false, icon, counter, + counterLabel, + 'aria-label': ariaLabel, dataTestId, ...rest }, @@ -56,6 +65,7 @@ export const IconButton = forwardRef( className, )} data-testid={dataTestId?.root} + aria-label={counterLabel ? `${ariaLabel}, ${counterLabel}` : ariaLabel} {...rest} > {loading ? ( diff --git a/packages/lido-app-ui/src/link-provider/Anchor.test.tsx b/packages/lido-app-ui/src/link-provider/Anchor.test.tsx index 99f01e9fc..32cd863cf 100644 --- a/packages/lido-app-ui/src/link-provider/Anchor.test.tsx +++ b/packages/lido-app-ui/src/link-provider/Anchor.test.tsx @@ -1,28 +1,11 @@ -import { createRef, forwardRef } from 'react' -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { createRef } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' -import { LinkProvider, type LinkComponentProps } from './LinkProvider' +import { LinkProvider } from './LinkProvider' +import { ExternalLink, RouterLink } from './linkStubs.test-utils' import { Anchor } from './Anchor' -const RouterLink = forwardRef( - ({ children, ...props }, ref) => ( - - {children} - - ), -) -RouterLink.displayName = 'RouterLink' - -const ExternalLink = forwardRef( - ({ children, ...props }, ref) => ( - - {children} - - ), -) -ExternalLink.displayName = 'ExternalLink' - const renderInProvider = (ui: JSX.Element) => render({ui}) @@ -40,7 +23,7 @@ describe('Anchor', () => { it('renders a plain anchor without a provider', () => { render(Keys) expect(isRouted()).toBe(false) - expect(screen.getByRole('link').getAttribute('href')).toBe('/keys') + expect(screen.getByRole('link')).toHaveAttribute('href', '/keys') }) it('routes internal hrefs through the provider component and forwards ref', () => { @@ -52,7 +35,7 @@ describe('Anchor', () => { ) const link = screen.getByRole('link') expect(isRouted()).toBe(true) - expect(link.getAttribute('href')).toBe('/keys') + expect(link).toHaveAttribute('href', '/keys') expect(link.className).toBe('x') expect(ref.current).toBe(link) }) @@ -94,6 +77,56 @@ describe('Anchor', () => { expect(isRouted()).toBe(true) }) + it('routes download={false}', () => { + renderInProvider( + + Keys + , + ) + expect(isRouted()).toBe(true) + }) + + describe('external', () => { + it('sets target and rel and keeps relative links off the router', () => { + renderInProvider( + + X + , + ) + const a = screen.getByRole('link', { name: 'X' }) + expect(a).toHaveAttribute('target', '_blank') + expect(a).toHaveAttribute('rel', 'noopener noreferrer') + expect(isRouted()).toBe(false) + }) + + it('lets an explicit rel win', () => { + render( + + X + , + ) + expect(screen.getByRole('link')).toHaveAttribute('rel', 'noopener') + }) + }) + + describe('disabled', () => { + it('renders an href-less link that ignores clicks, tabIndex and routing', () => { + const onClick = vi.fn() + renderInProvider( + + X + , + ) + const a = screen.getByRole('link', { name: 'X' }) + expect(a).not.toHaveAttribute('href') + expect(a).toHaveAttribute('aria-disabled', 'true') + expect(a).not.toHaveAttribute('tabindex') + expect(isRouted()).toBe(false) + fireEvent.click(a) + expect(onClick).not.toHaveBeenCalled() + }) + }) + describe('externalComponent', () => { const renderWithBoth = (ui: JSX.Element) => render( @@ -116,8 +149,8 @@ describe('Anchor', () => { ) const link = screen.getByRole('link') expect(isExternal()).toBe(true) - expect(link.getAttribute('target')).toBe('_blank') - expect(link.getAttribute('rel')).toBe('noopener') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener') }) it('routes internal hrefs with component, not externalComponent', () => { @@ -135,9 +168,9 @@ describe('Anchor', () => { it('renders a plain anchor without href', () => { const { container } = renderWithBoth(Disabled) const a = container.querySelector('a') - expect(a?.hasAttribute('data-router')).toBe(false) - expect(a?.hasAttribute('data-external')).toBe(false) - expect(a?.hasAttribute('href')).toBe(false) + expect(a).not.toHaveAttribute('data-router') + expect(a).not.toHaveAttribute('data-external') + expect(a).not.toHaveAttribute('href') }) it('renders internal hrefs plain when only externalComponent is given', () => { @@ -161,3 +194,34 @@ describe('Anchor', () => { }) }) }) + +describe('Anchor linkProps', () => { + const linkProps = { 'data-prefetch': 'false' } + + it('reaches the provider component', () => { + renderInProvider( + + Keys + , + ) + expect(screen.getByRole('link')).toHaveAttribute('data-prefetch', 'false') + }) + + it('never reaches a plain anchor', () => { + render( + + Keys + , + ) + expect(screen.getByRole('link')).not.toHaveAttribute('data-prefetch') + }) + + it('stays off the anchor when the link bypasses the provider', () => { + renderInProvider( + + Top + , + ) + expect(screen.getByRole('link')).not.toHaveAttribute('data-prefetch') + }) +}) diff --git a/packages/lido-app-ui/src/link-provider/Anchor.tsx b/packages/lido-app-ui/src/link-provider/Anchor.tsx index 174dfc738..857340c12 100644 --- a/packages/lido-app-ui/src/link-provider/Anchor.tsx +++ b/packages/lido-app-ui/src/link-provider/Anchor.tsx @@ -1,9 +1,22 @@ import { forwardRef, type AnchorHTMLAttributes } from 'react' -import { useLinkComponents, type LinkComponents } from './LinkProvider' +import { + useLinkComponents, + type LinkComponents, + type LinkRouterProps, +} from './LinkProvider' const ABSOLUTE_URL = /^([a-z][a-z\d+.-]*:|\/\/)/i +export type AnchorProps = AnchorHTMLAttributes & { + /** Opens in a new tab: defaults `target` to `_blank` and `rel` to `noopener noreferrer`; an explicit `rel` wins. */ + external?: boolean + /** Renders an href-less `role='link'` with `aria-disabled`, and drops `onClick`. */ + disabled?: boolean + /** Router-only props (e.g. `prefetch`), passed to `LinkProvider` components and never to a plain ``. */ + linkProps?: LinkRouterProps +} + const pickLink = ( { component, externalComponent }: LinkComponents, href: string, @@ -13,27 +26,40 @@ const pickLink = ( if ( href.startsWith('#') || (target && target !== '_self') || - download !== undefined + (download != null && download !== false) ) return undefined return component } /** `` that hands in-app hrefs to `LinkProvider`'s `component` and absolute URLs to its `externalComponent`. */ -export const Anchor = forwardRef< - HTMLAnchorElement, - AnchorHTMLAttributes ->((props, ref) => { - const links = useLinkComponents() - const { href } = props - - if (href !== undefined) { - const Link = pickLink(links, href, props) - if (Link) return - } - - // eslint-disable-next-line jsx-a11y/anchor-has-content -- children arrive via props - return -}) +export const Anchor = forwardRef( + ({ external, disabled, linkProps, ...rest }, ref) => { + const links = useLinkComponents() + const props: AnchorHTMLAttributes = { + ...rest, + ...(external && { + target: rest.target ?? '_blank', + rel: rest.rel ?? 'noopener noreferrer', + }), + ...(disabled && { + href: undefined, + onClick: undefined, + tabIndex: undefined, + role: rest.role ?? 'link', + 'aria-disabled': true, + }), + } + const { href } = props + + if (href !== undefined) { + const Link = pickLink(links, href, props) + if (Link) return + } + + // eslint-disable-next-line jsx-a11y/anchor-has-content -- children arrive via props + return + }, +) Anchor.displayName = 'Anchor' diff --git a/packages/lido-app-ui/src/link-provider/LinkProvider.stories.tsx b/packages/lido-app-ui/src/link-provider/LinkProvider.stories.tsx index b383a80c5..881afdd2e 100644 --- a/packages/lido-app-ui/src/link-provider/LinkProvider.stories.tsx +++ b/packages/lido-app-ui/src/link-provider/LinkProvider.stories.tsx @@ -9,7 +9,9 @@ import { import type { Meta, StoryObj } from '@storybook/react' import { StorySection } from '../../.storybook/components' +import { Breadcrumbs } from '../breadcrumbs' import { IconArrowNarrowUpRight, IconBell } from '../icons' +import { NavLink } from '../nav-link' import { SmallCard } from '../small-card' import { TextLink } from '../text-link' import { LinkProvider, type LinkComponentProps } from './LinkProvider' @@ -74,6 +76,16 @@ const DemoLinks = ({ withProvider }: { withProvider: boolean }) => { target=_blank: /keys + + NavLink: /keys + +
} @@ -86,7 +98,6 @@ const DemoLinks = ({ withProvider }: { withProvider: boolean }) => {

{ const meta: Meta = { title: 'Navigation/LinkProvider', component: LinkProvider, - tags: ['autodocs'], parameters: { controls: { disable: true }, - docs: { - description: { - component: - 'Routes app-ui links through your own components. `component` handles in-app hrefs (e.g. Next.js `Link`), `externalComponent` handles absolute URLs; both must forward `ref` to an ``. Without a provider links render a plain ``. Hash links, `download` links and links with a non-`_self` `target` always stay a plain ``. `TextLink` and `SmallCard` use it. Pass stable, module-level components: inline ones remount every link on each render.', - }, - }, }, } diff --git a/packages/lido-app-ui/src/link-provider/LinkProvider.test.tsx b/packages/lido-app-ui/src/link-provider/LinkProvider.test.tsx index 733f09972..0561ef766 100644 --- a/packages/lido-app-ui/src/link-provider/LinkProvider.test.tsx +++ b/packages/lido-app-ui/src/link-provider/LinkProvider.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { Anchor } from './Anchor' -import { LinkProvider } from './LinkProvider' +import { LinkProvider, type LinkComponent } from './LinkProvider' // Mirrors next/link's public shape: forwardRef, href accepts a UrlObject, extra props. type NextLikeProps = Omit, 'href'> & { @@ -31,6 +31,15 @@ describe('LinkProvider', () => { Keys , ) - expect(screen.getByRole('link').hasAttribute('data-next')).toBe(true) + expect(screen.getByRole('link')).toHaveAttribute('data-next') + }) + + it('types ref as an optional prop for adapters that read it', () => { + const Adapter: LinkComponent = ({ ref, ...props }) => ( + + x + + ) + expect(Adapter).toBeTypeOf('function') }) }) diff --git a/packages/lido-app-ui/src/link-provider/LinkProvider.tsx b/packages/lido-app-ui/src/link-provider/LinkProvider.tsx index 598809b39..188f1ba3b 100644 --- a/packages/lido-app-ui/src/link-provider/LinkProvider.tsx +++ b/packages/lido-app-ui/src/link-provider/LinkProvider.tsx @@ -7,15 +7,23 @@ import { type Ref, } from 'react' -export type LinkComponentProps = AnchorHTMLAttributes & { - href: string - ref?: Ref -} +/** Augment via `declare module '@lidofinance/lido-app-ui'` to type router-only props (e.g. Next's `prefetch`), set per link with `linkProps`. */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface LinkRouterProps {} + +export type LinkComponentProps = AnchorHTMLAttributes & + LinkRouterProps & { + href: string + ref?: Ref + } +/** Wrap adapters in `forwardRef`: under React 18 `ref` arrives as the second argument, never as a prop. */ export type LinkComponent = (props: LinkComponentProps) => ReactNode export type LinkComponents = { + /** Renders in-app hrefs (relative, not hash, `download` or non-`_self` `target`). */ component?: LinkComponent + /** Renders absolute URLs, including `mailto:` and protocol-relative `//`. */ externalComponent?: LinkComponent } @@ -25,7 +33,7 @@ export type LinkProviderProps = LinkComponents & { children?: ReactNode } -/** Renders app-ui links with your components: `component` for in-app hrefs (e.g. Next.js `Link`), `externalComponent` for absolute URLs; both must forward `ref` to an ``. Pass stable references (module-level, not inline functions): inline ones remount every link on each render. */ +/** Renders app-ui links with your components: `component` for in-app hrefs (e.g. Next.js `Link`), `externalComponent` for absolute URLs; both must be `forwardRef` components that forward `ref` to an ``. Pass stable references (module-level, not inline functions): inline ones remount every link on each render. In-app hash links, `download` links and non-`_self` `target` links stay a plain ``; absolute URLs always go to `externalComponent` when set. `TextLink`, `NavLink`, `SmallCard`, `LinkButton`, `Tag`, `Breadcrumbs` items and `Logo` with `href` use it. */ export const LinkProvider = ({ component, externalComponent, diff --git a/packages/lido-app-ui/src/link-provider/index.ts b/packages/lido-app-ui/src/link-provider/index.ts index 764a87a83..4aef195d9 100644 --- a/packages/lido-app-ui/src/link-provider/index.ts +++ b/packages/lido-app-ui/src/link-provider/index.ts @@ -3,4 +3,5 @@ export type { LinkComponent, LinkComponentProps, LinkProviderProps, + LinkRouterProps, } from './LinkProvider' diff --git a/packages/lido-app-ui/src/link-provider/linkStubs.test-utils.tsx b/packages/lido-app-ui/src/link-provider/linkStubs.test-utils.tsx new file mode 100644 index 000000000..8c44f1f95 --- /dev/null +++ b/packages/lido-app-ui/src/link-provider/linkStubs.test-utils.tsx @@ -0,0 +1,21 @@ +import { forwardRef } from 'react' + +import type { LinkComponentProps } from './LinkProvider' + +export const RouterLink = forwardRef( + ({ children, ...props }, ref) => ( + + {children} + + ), +) +RouterLink.displayName = 'RouterLink' + +export const ExternalLink = forwardRef( + ({ children, ...props }, ref) => ( + + {children} + + ), +) +ExternalLink.displayName = 'ExternalLink' diff --git a/packages/lido-app-ui/src/rich-input/RichInput.module.css b/packages/lido-app-ui/src/rich-input/RichInput.module.css index 4176a98d1..5976c276a 100644 --- a/packages/lido-app-ui/src/rich-input/RichInput.module.css +++ b/packages/lido-app-ui/src/rich-input/RichInput.module.css @@ -112,27 +112,8 @@ color: var(--lido-app-ui-color-text-secondary); } -.balanceError { - color: var(--lido-app-ui-color-text-error); -} - .errorMessage { @mixin font-body; color: var(--lido-app-ui-color-text-error); } - -.maxTag { - cursor: pointer; -} - -.maxTag:focus-visible { - outline: 2px solid var(--lido-app-ui-color-border-active); - outline-offset: 2px; -} - -.maxTagDisabled { - cursor: not-allowed; - opacity: 0.5; - pointer-events: none; -} diff --git a/packages/lido-app-ui/src/rich-input/RichInput.stories.tsx b/packages/lido-app-ui/src/rich-input/RichInput.stories.tsx index fe6060642..82c488dc2 100644 --- a/packages/lido-app-ui/src/rich-input/RichInput.stories.tsx +++ b/packages/lido-app-ui/src/rich-input/RichInput.stories.tsx @@ -17,7 +17,6 @@ const tokenSelector = ( const meta: Meta = { title: 'Inputs & Controls/RichInput', component: RichInput, - tags: ['autodocs'], args: { label: 'Amount', placeholder: '0', @@ -28,24 +27,14 @@ const meta: Meta = { tokenSelector, }, argTypes: { - label: { description: 'Caption above the field' }, - secondaryValue: { description: 'Fiat equivalent, bottom left' }, - balance: { description: 'Wallet balance, bottom right' }, showMaxButton: { - description: 'Toggles the Max pill', control: { type: 'boolean' }, }, tokenSelector: { - description: 'Slot for a `TokenSelector`', + control: false, table: { defaultValue: { summary: 'TokenSelector' } }, }, - error: { - description: 'Marks the card as invalid and recolours the balance', - }, - errorText: { description: 'Message under the field' }, - maxTooltip: { description: 'Explains what Max actually fills in' }, maxTooltipPosition: { - description: 'Placement of the Max tooltip', control: { type: 'select' }, options: [ 'top', @@ -58,18 +47,9 @@ const meta: Meta = { 'bottom-right', ], }, - maxDisabled: { - description: 'Disables the Max shortcut, leaving the field editable', - }, }, parameters: { layout: 'padded', - docs: { - description: { - component: - 'The amount field for supply and borrow forms: token picker, fiat equivalent, balance and a Max shortcut in one card. Input is filtered to decimals as you type. `disabled` dims the card and hides the Max shortcut; `maxDisabled` only dims the Max shortcut.', - }, - }, }, } diff --git a/packages/lido-app-ui/src/rich-input/RichInput.test.tsx b/packages/lido-app-ui/src/rich-input/RichInput.test.tsx index e9c859ce6..7fce79a34 100644 --- a/packages/lido-app-ui/src/rich-input/RichInput.test.tsx +++ b/packages/lido-app-ui/src/rich-input/RichInput.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' import { RichInput } from './RichInput' @@ -26,3 +27,35 @@ describe('RichInput', () => { expect(document.activeElement).not.toBe(screen.getByTestId('input')) }) }) + +describe('RichInput Max', () => { + it('calls onMaxClick when Max is clicked', async () => { + const onMaxClick = vi.fn() + render() + await userEvent.click(screen.getByRole('button', { name: 'Max' })) + expect(onMaxClick).toHaveBeenCalledTimes(1) + }) + + it('disables Max natively with maxDisabled', async () => { + const onMaxClick = vi.fn() + render() + const max = screen.getByRole('button', { name: 'Max' }) + expect(max).toBeDisabled() + await userEvent.click(max) + expect(onMaxClick).not.toHaveBeenCalled() + }) + + it('does not focus the input when Max is clicked', async () => { + render( undefined} />) + await userEvent.click(screen.getByRole('button', { name: 'Max' })) + expect(screen.getByRole('textbox')).not.toHaveFocus() + }) + + it('keeps Max a native button without onMaxClick', async () => { + render() + const max = screen.getByRole('button', { name: 'Max' }) + expect(max.tagName).toBe('BUTTON') + await userEvent.click(max) + expect(screen.getByRole('textbox')).not.toHaveFocus() + }) +}) diff --git a/packages/lido-app-ui/src/rich-input/RichInput.tsx b/packages/lido-app-ui/src/rich-input/RichInput.tsx index ba689addb..a9dc6e879 100644 --- a/packages/lido-app-ui/src/rich-input/RichInput.tsx +++ b/packages/lido-app-ui/src/rich-input/RichInput.tsx @@ -3,7 +3,6 @@ import { useRef, type ChangeEvent, type InputHTMLAttributes, - type KeyboardEvent, type MouseEvent, type MouseEventHandler, type ReactNode, @@ -11,7 +10,7 @@ import { import cn from 'classnames' import { InfoTooltip } from '../info-tooltip' -import { Tag, type TagProps } from '../tag' +import { Tag } from '../tag' import { Tooltip, type TooltipPosition } from '../tooltip' import styles from './RichInput.module.css' @@ -29,41 +28,25 @@ export type RichInputDataTestId = { export type RichInputProps = InputHTMLAttributes & { label?: ReactNode labelTooltip?: ReactNode + /** Fiat equivalent, bottom left. */ secondaryValue?: ReactNode + /** Wallet balance, bottom right. */ balance?: ReactNode + /** Slot for a `TokenSelector`. */ tokenSelector?: ReactNode + /** Marks the card invalid. */ error?: boolean errorText?: ReactNode showMaxButton?: boolean - onMaxClick?: MouseEventHandler + onMaxClick?: MouseEventHandler + /** Dims the Max shortcut, leaving the field editable. */ maxDisabled?: boolean + /** Explains what Max actually fills in. */ maxTooltip?: ReactNode maxTooltipPosition?: TooltipPosition dataTestId?: RichInputDataTestId } -const MaxTag = ({ - onClick, - className, - disabled, - ...rest -}: TagProps & { disabled?: boolean }) => ( - ) => { - if (disabled) return - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - onClick?.(event as unknown as MouseEvent) - }} - {...rest} - /> -) - // Keep only digits and a single decimal point; comma is treated as the // decimal separator and normalized to a dot (en-US canonical form). const sanitizeDecimal = (raw: string): string => { @@ -75,6 +58,9 @@ const sanitizeDecimal = (raw: string): string => { return head + tail } +const noop = () => undefined + +/** Amount field for supply and borrow forms: token picker, fiat equivalent, balance and a Max shortcut in one card; input is filtered to decimals as you type. `disabled` dims the card and hides Max. */ export const RichInput = ({ label, labelTooltip, @@ -105,6 +91,17 @@ export const RichInput = ({ const showMax = showMaxButton && !disabled const hasBottomRow = secondaryValue != null || balance != null || showMax + const maxTag = ( + + Max + + ) + const handleChange = (event: ChangeEvent) => { const { value: rawValue, selectionStart } = event.target const sanitized = sanitizeDecimal(rawValue) @@ -194,30 +191,16 @@ export const RichInput = ({ {balance} ) : null} - {showMax ? ( - maxTooltip != null ? ( - - - Max - - - ) : ( - - Max - - ) + {showMax && maxTooltip != null ? ( + + {maxTag} + + ) : showMax ? ( + maxTag ) : null}

diff --git a/packages/lido-app-ui/src/small-card/SmallCard.stories.tsx b/packages/lido-app-ui/src/small-card/SmallCard.stories.tsx index 0ef4a59fc..38f5fd31d 100644 --- a/packages/lido-app-ui/src/small-card/SmallCard.stories.tsx +++ b/packages/lido-app-ui/src/small-card/SmallCard.stories.tsx @@ -7,17 +7,20 @@ import { SmallCard } from '.' const meta: Meta = { title: 'Cards/SmallCard', component: SmallCard, - tags: ['autodocs'], args: { logo: , icon: , title: 'CSM Sentinel', description: 'Provides your CSM Node Operator events to the telegram chats', href: 'https://lido.fi', + external: true, }, argTypes: { title: { control: { type: 'text' } }, description: { control: { type: 'text' } }, + external: { + control: { type: 'boolean' }, + }, }, // SmallCard sizes to its content/container, so the canvas needs a width — // kept out of each story's own code so the example stays just @@ -32,12 +35,6 @@ const meta: Meta = { parameters: { storyBackground: 'page', layout: 'padded', - docs: { - description: { - component: - 'A clickable card that always renders as a link: an optional `image` (fixed 120px height), an optional `logo` + title + an optional trailing `icon`, a `description`, and an optional `footer` slot that wraps arbitrary content (e.g. a row of `Tag`s). Title and description wrap to multiple lines rather than truncating. Background lightens from the default surface to white on hover.', - }, - }, }, } diff --git a/packages/lido-app-ui/src/small-card/SmallCard.test.tsx b/packages/lido-app-ui/src/small-card/SmallCard.test.tsx index 462e7d0f2..714631841 100644 --- a/packages/lido-app-ui/src/small-card/SmallCard.test.tsx +++ b/packages/lido-app-ui/src/small-card/SmallCard.test.tsx @@ -1,19 +1,10 @@ -import { forwardRef } from 'react' import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { LinkProvider, type LinkComponentProps } from '../link-provider' +import { LinkProvider } from '../link-provider' +import { RouterLink } from '../link-provider/linkStubs.test-utils' import { SmallCard } from './SmallCard' -const RouterLink = forwardRef( - ({ children, ...props }, ref) => ( - - {children} - - ), -) -RouterLink.displayName = 'RouterLink' - describe('SmallCard with LinkProvider', () => { it('routes internal links', () => { render( @@ -21,6 +12,16 @@ describe('SmallCard with LinkProvider', () => { , ) - expect(screen.getByRole('link').hasAttribute('data-router')).toBe(true) + expect(screen.getByRole('link')).toHaveAttribute('data-router') + }) +}) + +describe('SmallCard external', () => { + it('sets target and rel', () => { + render() + const link = screen.getByRole('link') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + expect(link).not.toHaveAttribute('external') }) }) diff --git a/packages/lido-app-ui/src/small-card/SmallCard.tsx b/packages/lido-app-ui/src/small-card/SmallCard.tsx index b6c9304de..297ad84b8 100644 --- a/packages/lido-app-ui/src/small-card/SmallCard.tsx +++ b/packages/lido-app-ui/src/small-card/SmallCard.tsx @@ -1,8 +1,8 @@ -import type { AnchorHTMLAttributes, ReactNode } from 'react' +import { forwardRef, type AnchorHTMLAttributes, type ReactNode } from 'react' import cn from 'classnames' -import { Anchor } from '../link-provider/Anchor' +import { Anchor, type AnchorProps } from '../link-provider/Anchor' import styles from './SmallCard.module.css' export type SmallCardDataTestId = { @@ -25,63 +25,78 @@ export type SmallCardProps = Omit< /** Trailing glyph next to the title — an external-link arrow, a badge, etc. */ icon?: ReactNode title: ReactNode + /** Opens in a new tab: sets `target="_blank"` and `rel="noopener noreferrer"` unless given. */ + external?: boolean + linkProps?: AnchorProps['linkProps'] description?: ReactNode /** Arbitrary content below the description — wraps in a row, e.g. a list of `Tag`s. */ footer?: ReactNode dataTestId?: SmallCardDataTestId } -export const SmallCard = ({ - image, - logo, - icon, - title, - description, - footer, - className, - dataTestId, - ...rest -}: SmallCardProps) => ( - -
- {image != null ? ( -
- {image} -
- ) : null} -
- {logo != null ? ( - - {logo} - +/** Clickable card that always renders as a link: optional `image`, `logo`, title with trailing `icon`, `description` and `footer`; title and description wrap instead of truncating. */ +export const SmallCard = forwardRef( + ( + { + image, + logo, + icon, + title, + description, + footer, + className, + dataTestId, + ...rest + }, + ref, + ) => ( + +
+ {image != null ? ( +
+ {image} +
) : null} - - {title} - - {icon != null ? ( -
- {description != null ? ( -

- {description} -

+ {footer != null ? ( +
+ {footer} +
) : null} -
- {footer != null ? ( -
- {footer} -
- ) : null} - + + ), ) + +SmallCard.displayName = 'SmallCard' diff --git a/packages/lido-app-ui/src/smoke.test.tsx b/packages/lido-app-ui/src/smoke.test.tsx index a02e5303a..e464cbf95 100644 --- a/packages/lido-app-ui/src/smoke.test.tsx +++ b/packages/lido-app-ui/src/smoke.test.tsx @@ -10,7 +10,7 @@ import { AddressButton } from './address-button' import { Badge } from './badge' import { Banner } from './banner' import { Breadcrumbs } from './breadcrumbs' -import { Button } from './button' +import { Button, LinkButton } from './button' import { Card } from './card' import { Checkbox } from './checkbox' import { Copy } from './copy' @@ -107,6 +107,7 @@ const cases: Array<[string, () => JSX.Element]> = [ ['Badge', () => Badge text], ['Banner', () => ], ['Button', () => ], + ['LinkButton', () => Keys], ['Card', () => Card], ['Checkbox', () => ], ['CloseButton', () => ], diff --git a/packages/lido-app-ui/src/tag/Tag.module.css b/packages/lido-app-ui/src/tag/Tag.module.css index f585d002e..643a91e7f 100644 --- a/packages/lido-app-ui/src/tag/Tag.module.css +++ b/packages/lido-app-ui/src/tag/Tag.module.css @@ -4,6 +4,8 @@ --local-text: var(--lido-app-ui-color-text-secondary); --local-border-active: var(--lido-app-ui-color-border-default); + @mixin font-description-semibold; + display: inline-flex; align-items: center; gap: 4px; @@ -13,18 +15,30 @@ border-radius: var(--lido-app-ui-border-radius-badge); background-color: var(--local-bg); color: var(--local-text); - font-family: var(--lido-app-ui-font-family); - font-size: var(--lido-app-ui-font-size-tooltip); - font-weight: var(--lido-app-ui-font-weight-semibold); - line-height: var(--lido-app-ui-line-height-description); - letter-spacing: var(--lido-app-ui-letter-spacing-tooltip); white-space: nowrap; } -.interactive:hover { +.interactive { + margin: 0; + cursor: pointer; + text-decoration: none; +} + +.interactive:not(:disabled, [aria-disabled='true']):hover { background-color: var(--local-bg-hover); } +.interactive:focus-visible, +.closeButton:focus-visible { + outline: 2px solid var(--lido-app-ui-color-border-active); + outline-offset: 0; +} + +.interactive:is(:disabled, [aria-disabled='true']) { + pointer-events: none; + opacity: 0.5; +} + .isActive { border-color: var(--local-border-active); } @@ -51,6 +65,7 @@ flex-shrink: 0; width: 16px; height: 16px; + border-radius: 50%; color: inherit; cursor: pointer; } diff --git a/packages/lido-app-ui/src/tag/Tag.stories.tsx b/packages/lido-app-ui/src/tag/Tag.stories.tsx index a143b0d2c..ca9815099 100644 --- a/packages/lido-app-ui/src/tag/Tag.stories.tsx +++ b/packages/lido-app-ui/src/tag/Tag.stories.tsx @@ -7,7 +7,6 @@ import { Tag } from '.' const meta: Meta = { title: 'Tags & Badges/Tag', component: Tag, - tags: ['autodocs'], args: { variant: 'default', children: 'Tag', @@ -26,28 +25,14 @@ const meta: Meta = { ], control: { type: 'select' }, }, - children: { description: 'Tag label', control: { type: 'text' } }, + children: { control: { type: 'text' } }, active: { - description: 'Shows the variant-coloured active border', control: { type: 'boolean' }, }, - icon: { description: 'Optional glyph rendered next to the label' }, iconPosition: { - description: 'Side the icon renders on', options: ['left', 'right'], control: { type: 'inline-radio' }, }, - onClose: { - description: 'Renders a trailing close button when provided', - }, - }, - parameters: { - docs: { - description: { - component: - 'Compact pill for filters, labels, and removable selections. Text only by default, with room for a leading icon and a trailing close button.', - }, - }, }, } @@ -161,7 +146,8 @@ export const Interactive: Story = { parameters: { docs: { description: { - story: 'Tags only react to hover when they have an `onClick`.', + story: + '`onClick` makes the span a keyboard-operable `role="button"`; `as="button"` renders a native ` + ) : null} + + ) + + if (isAction) { + return ( + } + className={classes} + data-testid={dataTestId?.root} + {...({ href, onClick, disabled, ...rest } as ActionElementProps)} + > + {content} + + ) + } + + if (isSpanAction) { + const { onKeyDown, tabIndex = 0, ...spanRest } = rest + return ( + } + role='button' + tabIndex={tabIndex} + className={classes} + data-testid={dataTestId?.root} + onClick={onClick} + onKeyDown={(event: KeyboardEvent) => { + onKeyDown?.(event) + if (event.defaultPrevented || event.target !== event.currentTarget) + return + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + event.currentTarget.click() + }} + {...spanRest} + > + {content} + + ) + } + + return ( + // removable tags keep a passive onClick; the close button is the control + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions - ) : null + ) + }, +) - return ( - - {iconPosition === 'left' ? iconElement : null} - {children} - {iconPosition === 'right' ? iconElement : null} - {onClose ? ( - - ) : null} - - ) -} +Tag.displayName = 'Tag' diff --git a/packages/lido-app-ui/src/text-link/TextLink.module.css b/packages/lido-app-ui/src/text-link/TextLink.module.css index 22bcf157f..cab2a8903 100644 --- a/packages/lido-app-ui/src/text-link/TextLink.module.css +++ b/packages/lido-app-ui/src/text-link/TextLink.module.css @@ -3,8 +3,6 @@ align-items: center; gap: 4px; - @mixin font-body; - --local-color: var(--lido-app-ui-color-text-active); --local-color-hover: var(--lido-app-ui-color-text-active-hover); @@ -13,6 +11,20 @@ cursor: pointer; } +.body { + @mixin font-body; +} + +.inherit { + display: inline; + font: inherit; + letter-spacing: inherit; +} + +.inherit.withIcon { + display: inline-flex; +} + .secondary { --local-color: var(--lido-app-ui-color-text-secondary); --local-color-hover: var(--lido-app-ui-color-text-active-hover); @@ -35,7 +47,6 @@ .link[aria-disabled='true'] { color: var(--lido-app-ui-color-text-disabled); pointer-events: none; - cursor: not-allowed; } .icon { diff --git a/packages/lido-app-ui/src/text-link/TextLink.stories.tsx b/packages/lido-app-ui/src/text-link/TextLink.stories.tsx index c75f2c913..92568422d 100644 --- a/packages/lido-app-ui/src/text-link/TextLink.stories.tsx +++ b/packages/lido-app-ui/src/text-link/TextLink.stories.tsx @@ -7,7 +7,6 @@ import { TextLink } from '.' const meta: Meta = { title: 'Buttons/TextLink', component: TextLink, - tags: ['autodocs'], args: { children: 'View on Explorer', href: 'https://etherscan.io', @@ -15,33 +14,22 @@ const meta: Meta = { variant: 'primary', }, argTypes: { - children: { description: 'Link text', control: { type: 'text' } }, + children: { control: { type: 'text' } }, external: { - description: 'Adds target="_blank" and rel="noreferrer"', control: { type: 'boolean' }, }, variant: { - description: - 'primary is blue; secondary is muted gray, blue on hover — footers, meta links', control: { type: 'select' }, options: ['primary', 'secondary'], }, - icon: { - description: 'Optional trailing glyph, sized to 20×20 by the component', + size: { + control: { type: 'select' }, + options: ['body', 'inherit'], }, disabled: { - description: 'Drops the href and blocks clicks/keyboard focus', control: { type: 'boolean' }, }, }, - parameters: { - docs: { - description: { - component: - 'Inline link — blue by default, muted `secondary` variant for footers and meta links. `external` handles the target and rel attributes for you.', - }, - }, - }, } export default meta @@ -52,7 +40,7 @@ export const Basic: Story = {} export const Variants: Story = { render: () => ( - Internal link + Same-tab link Secondary link @@ -72,7 +60,34 @@ export const Variants: Story = { docs: { description: { story: - 'Internal, secondary, external, with a trailing icon, and disabled. The icon takes the link colour through `currentColor`.', + 'Same-tab, secondary, external (new tab), with a trailing icon, and disabled. The icon takes the link colour through `currentColor`.', + }, + }, + }, +} + +export const InText: Story = { + render: () => ( +

+ Read the{' '} + + Terms of Use and the long Privacy Notice + {' '} + before you continue. +

+ ), + parameters: { + controls: { disable: true }, + docs: { + description: { + story: + '`size="inherit"` lets the link take the paragraph font and wrap with the text.', }, }, }, diff --git a/packages/lido-app-ui/src/text-link/TextLink.test.tsx b/packages/lido-app-ui/src/text-link/TextLink.test.tsx index e23bb298b..48fa5aa37 100644 --- a/packages/lido-app-ui/src/text-link/TextLink.test.tsx +++ b/packages/lido-app-ui/src/text-link/TextLink.test.tsx @@ -1,8 +1,8 @@ -import { forwardRef } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { LinkProvider, type LinkComponentProps } from '../link-provider' +import { LinkProvider } from '../link-provider' +import { RouterLink } from '../link-provider/linkStubs.test-utils' import { TextLink } from './TextLink' import styles from './TextLink.module.css' @@ -13,16 +13,12 @@ describe('TextLink', () => { A , ) - expect(screen.getByText('A').classList.contains(styles.secondary)).toBe( - true, - ) + expect(screen.getByText('A')).toHaveClass(styles.secondary) }) it('does not add the secondary class by default', () => { render(A) - expect(screen.getByText('A').classList.contains(styles.secondary)).toBe( - false, - ) + expect(screen.getByText('A')).not.toHaveClass(styles.secondary) }) it('sets target and rel when external', () => { @@ -32,23 +28,70 @@ describe('TextLink', () => { , ) const el = screen.getByText('A') - expect(el.getAttribute('target')).toBe('_blank') - expect(el.getAttribute('rel')).toBe('noopener noreferrer') + expect(el).toHaveAttribute('target', '_blank') + expect(el).toHaveAttribute('rel', 'noopener noreferrer') }) -}) -const RouterLink = forwardRef( - ({ children, ...props }, ref) => ( -
- {children} - - ), -) -RouterLink.displayName = 'RouterLink' + it('keeps disabled state when a consumer passes tabIndex or aria-disabled', () => { + const onClick = vi.fn() + render( + + A + , + ) + const el = screen.getByRole('link', { name: 'A' }) + expect(el).not.toHaveAttribute('href') + expect(el).not.toHaveAttribute('tabindex') + expect(el).toHaveAttribute('aria-disabled', 'true') + fireEvent.click(el) + expect(onClick).not.toHaveBeenCalled() + }) + + it('forwards no external or disabled attribute to the DOM', () => { + render( + + A + , + ) + const el = screen.getByRole('link', { name: 'A' }) + expect(el).not.toHaveAttribute('external') + expect(el).not.toHaveAttribute('disabled') + }) + + it('uses body size by default and inherit on demand', () => { + render( + <> + A + + B + + , + ) + expect(screen.getByText('A')).toHaveClass(styles.body) + expect(screen.getByText('A')).not.toHaveClass(styles.inherit) + expect(screen.getByText('B')).toHaveClass(styles.inherit) + expect(screen.getByText('B')).not.toHaveClass(styles.body) + }) + + it('marks links with an icon', () => { + render( + }> + A + , + ) + expect(screen.getByRole('link', { name: 'A' })).toHaveClass(styles.withIcon) + }) +}) describe('TextLink with LinkProvider', () => { it('routes internal links and keeps consumer onClick', () => { - const onClick = vi.fn() + const onClick = vi.fn((e: React.MouseEvent) => e.preventDefault()) render( @@ -57,7 +100,7 @@ describe('TextLink with LinkProvider', () => { , ) const link = screen.getByRole('link', { name: 'Terms' }) - expect(link.hasAttribute('data-router')).toBe(true) + expect(link).toHaveAttribute('data-router') fireEvent.click(link) expect(onClick).toHaveBeenCalledTimes(1) }) @@ -71,7 +114,7 @@ describe('TextLink with LinkProvider', () => { , ) const a = container.querySelector('a') - expect(a?.hasAttribute('data-router')).toBe(false) - expect(a?.hasAttribute('href')).toBe(false) + expect(a).not.toHaveAttribute('data-router') + expect(a).not.toHaveAttribute('href') }) }) diff --git a/packages/lido-app-ui/src/text-link/TextLink.tsx b/packages/lido-app-ui/src/text-link/TextLink.tsx index 8346f8d11..40ef5fa07 100644 --- a/packages/lido-app-ui/src/text-link/TextLink.tsx +++ b/packages/lido-app-ui/src/text-link/TextLink.tsx @@ -1,59 +1,76 @@ -import type { AnchorHTMLAttributes, ReactNode } from 'react' +import { forwardRef, type AnchorHTMLAttributes, type ReactNode } from 'react' import cn from 'classnames' -import { Anchor } from '../link-provider/Anchor' +import { Anchor, type AnchorProps } from '../link-provider/Anchor' import styles from './TextLink.module.css' export type TextLinkVariant = 'primary' | 'secondary' +export type TextLinkSize = 'body' | 'inherit' + export type TextLinkDataTestId = { root?: string icon?: string } export type TextLinkProps = AnchorHTMLAttributes & { + /** `secondary` is muted gray, blue on hover; for footers and meta links. */ variant?: TextLinkVariant + /** `inherit` takes font and display from the surrounding text so the link wraps with it; `body` is 14/24. */ + size?: TextLinkSize + /** Trailing icon (e.g. arrow). */ icon?: ReactNode + /** Opens in a new tab (target/rel); adds no icon, pass `icon` for an arrow. */ external?: boolean + /** Drops the href and blocks clicks and keyboard focus. */ disabled?: boolean + linkProps?: AnchorProps['linkProps'] dataTestId?: TextLinkDataTestId } -export const TextLink = ({ - variant = 'primary', - icon, - external = false, - disabled = false, - className, - children, - target, - rel, - href, - onClick, - dataTestId, - ...rest -}: TextLinkProps) => ( - - {children} - {icon != null ? ( - - ) : null} - +/** Inline link, blue by default. `external` opens a new tab, so an in-app href with it stays a plain ``; otherwise `LinkProvider` sends relative hrefs to `component` and absolute ones to `externalComponent`. */ +export const TextLink = forwardRef( + ( + { + variant = 'primary', + size = 'body', + icon, + external = false, + disabled = false, + className, + children, + dataTestId, + ...rest + }, + ref, + ) => ( + + {children} + {icon != null ? ( + + ) : null} + + ), ) + +TextLink.displayName = 'TextLink' diff --git a/packages/lido-app-ui/src/text-link/index.ts b/packages/lido-app-ui/src/text-link/index.ts index 82edeaaf2..d62cffe55 100644 --- a/packages/lido-app-ui/src/text-link/index.ts +++ b/packages/lido-app-ui/src/text-link/index.ts @@ -2,5 +2,6 @@ export { TextLink } from './TextLink' export type { TextLinkProps, TextLinkVariant, + TextLinkSize, TextLinkDataTestId, } from './TextLink' diff --git a/packages/lido-app-ui/src/utils/ActionElement.stories.tsx b/packages/lido-app-ui/src/utils/ActionElement.stories.tsx index 5639c001d..ecb53189a 100644 --- a/packages/lido-app-ui/src/utils/ActionElement.stories.tsx +++ b/packages/lido-app-ui/src/utils/ActionElement.stories.tsx @@ -15,7 +15,6 @@ type DemoProps = { const meta: Meta = { title: 'Internal/ActionElement', component: ActionElement as ComponentType, - tags: ['autodocs'], args: { children: 'Action', onClick: fn(), @@ -25,27 +24,6 @@ const meta: Meta = { href: { control: { type: 'text' } }, disabled: { control: { type: 'boolean' } }, }, - // ActionElement is unstyled; this only makes the demo elements visible. - decorators: [ - (Story) => ( -
- -
- ), - ], - parameters: { - docs: { - description: { - component: - "Internal primitive (not exported from the package yet) for upcoming components. Renders an `
` through `Anchor`, so `LinkProvider` applies, when `href` is set, otherwise a `