diff --git a/apps/docs/content/docs/components/actions/link.mdx b/apps/docs/content/docs/components/actions/link.mdx index 9fa51de4..0191956e 100644 --- a/apps/docs/content/docs/components/actions/link.mdx +++ b/apps/docs/content/docs/components/actions/link.mdx @@ -14,26 +14,28 @@ description: Link component for inline and standalone navigation. | Guidance | Practices | | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Do | Use `tone="inverted"` only on dark backgrounds. On light backgrounds, it is hard to read. | +| Do | Use the default `accent` tone for links that should draw attention. | | Do | Use `isStandalone` for links that are not part of a sentence, such as card links and nav items. Leave it `false` for inline links. | ## Tone -`Link` has three tones: `brand` (default), `neutral`, and `inverted`. Use `neutral` for a more -subtle link. Use `inverted` on dark backgrounds. +`Link` has two tones: `accent` (default) and `neutral`. Use `neutral` when the surrounding content +should have more emphasis. ## Standalone Use `isStandalone` when a link stands on its own. Leave it `false` for links inside paragraph text. -- `isStandalone={true}`: no underline until hover. -- `isStandalone={false}`: underlined inline link style. +- `isStandalone={true}`: no underline until hover or press and a structural target of at least 24 × + 24 CSS pixels. +- `isStandalone={false}`: underlined inline link style that retains the target-size exception for + links within prose. - Brand + Accent Neutral -
- - Inverted - -
); } diff --git a/packages/@luke-ui/react/src/link/index.tsx b/packages/@luke-ui/react/src/link/index.tsx index 8fac704f..b9a3d0bd 100644 --- a/packages/@luke-ui/react/src/link/index.tsx +++ b/packages/@luke-ui/react/src/link/index.tsx @@ -9,9 +9,9 @@ import { cx } from '../utils/index.js'; interface LinkVariantProps extends NonNullable {} interface LinkStyleProps { - /** Hides underline until hover. */ + /** Hides the underline until hover or press and provides a structural 24px target. */ isStandalone?: LinkVariantProps['isStandalone']; - /** Sets the link tone. */ + /** Sets the link tone. @default 'accent' */ tone?: LinkVariantProps['tone']; } diff --git a/packages/@luke-ui/react/src/link/link.stories.tsx b/packages/@luke-ui/react/src/link/link.stories.tsx index 579a22ad..2f2bbc77 100644 --- a/packages/@luke-ui/react/src/link/link.stories.tsx +++ b/packages/@luke-ui/react/src/link/link.stories.tsx @@ -1,7 +1,7 @@ import type { LinkProps } from '@luke-ui/react/link'; import { Link } from '@luke-ui/react/link'; import type { CSSProperties } from 'react'; -import { expect } from 'storybook/test'; +import { expect, fn, userEvent } from 'storybook/test'; import preview from '../../.storybook/preview.js'; const meta = preview.meta({ @@ -23,12 +23,6 @@ const stackStyle = { maxInlineSize: '40rem', } as const satisfies CSSProperties; -const darkPanelStyle = { - backgroundColor: 'oklch(0.2 0 0)', - paddingBlock: '0.125rem', - paddingInline: '0.25rem', -} as const satisfies CSSProperties; - /** * Use `Link` for navigation and external destinations while preserving native * anchor behavior. @@ -36,30 +30,30 @@ const darkPanelStyle = { export const Default = meta.story({ args: baseArgs, play: async ({ canvas }) => { - await expect(canvas.getByRole('link', { name: 'Link' })).toBeInTheDocument(); + const link = canvas.getByRole('link', { name: 'Link' }); + await userEvent.tab(); + await expect(link).toHaveFocus(); + await expect(getComputedStyle(link).outlineStyle).toBe('solid'); + await expect(getComputedStyle(link).outlineWidth).toBe('2px'); + await expect(getComputedStyle(link).outlineOffset).toBe('2px'); }, }); /** - * Link tone controls contrast against surrounding UI. Use `neutral` for - * reduced emphasis and `inverted` on dark surfaces. + * Use the default `accent` tone for emphasis or `neutral` when the surrounding + * content should lead. */ export const Tone = meta.story({ args: { ...baseArgs, - children: 'Brand (default)', + children: 'Accent (default)', } satisfies Partial, render: (props) => (
- + Neutral -
- - Inverted - -
), }); @@ -81,4 +75,39 @@ export const Standalone = meta.story({

), + play: async ({ canvas, step }) => { + const standalone = canvas.getByRole('link', { name: 'Standalone link' }); + const inline = canvas.getByRole('link', { name: 'inline link' }); + + await step('standalone links provide the structural target', async () => { + await expect(getComputedStyle(standalone).minBlockSize).toBe('24px'); + await expect(getComputedStyle(standalone).minInlineSize).toBe('24px'); + }); + + await step('inline links retain the prose target-size exception', async () => { + await expect(getComputedStyle(inline).minBlockSize).toBe('0px'); + await expect(getComputedStyle(inline).minInlineSize).toBe('0px'); + }); + }, +}); + +/** + * Disabled links remain visible but cannot navigate or respond to interaction. + */ +export const Disabled = meta.story({ + args: { + ...baseArgs, + isDisabled: true, + onPress: fn(), + }, + play: async ({ args, canvas }) => { + const link = canvas.getByRole('link', { name: 'Link' }); + const restingColor = getComputedStyle(link).color; + + await userEvent.hover(link); + await userEvent.click(link); + + await expect(getComputedStyle(link).color).toBe(restingColor); + await expect(args.onPress).not.toHaveBeenCalled(); + }, }); diff --git a/packages/@luke-ui/react/src/link/link.visual.test.tsx b/packages/@luke-ui/react/src/link/link.visual.test.tsx index 52e10005..df138e7a 100644 --- a/packages/@luke-ui/react/src/link/link.visual.test.tsx +++ b/packages/@luke-ui/react/src/link/link.visual.test.tsx @@ -1,26 +1,20 @@ -import type { CSSProperties } from 'react'; -import { test } from 'vite-plus/test'; -import { page } from 'vite-plus/test/context'; +import { expect, test } from 'vite-plus/test'; +import { cdp, page, userEvent } from 'vite-plus/test/context'; import { captureVisual, + captureVisualAppearance, focusViaKeyboard, + Grid, renderVisual, Stack, + visualAppearances, } from '../test-utils/render-visual.js'; import { Link } from './index.js'; -const darkPanelStyle = { - backgroundColor: 'oklch(0.2 0 0)', - paddingBlock: '0.25rem', - paddingInline: '0.5rem', -} satisfies CSSProperties; - test('tones and states', async () => { const locator = renderVisual( - - Brand link - + Accent link Neutral link @@ -30,11 +24,6 @@ test('tones and states', async () => { Disabled link -
- - Inverted link - -
, ); @@ -51,3 +40,101 @@ test('keyboard focus ring', async () => { await focusViaKeyboard(page.getByRole('link', { name: 'Focus link' })); await captureVisual(scene, 'link/focus-visible'); }); + +test.each(visualAppearances)('navigation states: $theme $mode', async (appearance) => { + const scene = renderVisual( + + + Accent + + + Neutral + + + Disabled + + , + appearance, + ); + const accent = page.getByRole('link', { name: 'Accent' }); + const neutral = page.getByRole('link', { name: 'Neutral' }); + const disabled = page.getByRole('link', { name: 'Disabled' }); + + expect(getComputedStyle(accent.element()).color).not.toBe( + getComputedStyle(neutral.element()).color, + ); + expect(getComputedStyle(disabled.element()).opacity).toBe('0.55'); + + await captureVisualAppearance(scene, 'link/navigation-states', appearance); +}); + +test.each(visualAppearances)('interactive states: $theme $mode', async (appearance) => { + const scene = renderVisual( + + + Destination + + , + appearance, + ); + const link = page.getByRole('link', { name: 'Destination' }); + await expect.element(link).toBeVisible(); + + await captureVisualAppearance(scene, 'link/resting', appearance); + await userEvent.hover(link); + await captureVisualAppearance(scene, 'link/hover', appearance); + await userEvent.unhover(link); + await focusViaKeyboard(link); + await captureVisualAppearance(scene, 'link/focus-visible', appearance); + await userEvent.keyboard('{Enter>}'); + await expect.element(link).toHaveAttribute('data-pressed', 'true'); + await captureVisualAppearance(scene, 'link/pressed', appearance); + await userEvent.keyboard('{/Enter}'); +}); + +test('forced-colors navigation states', async () => { + await emulateForcedColors('active'); + + try { + const scene = renderVisual( + + + Resting + + + Hovered + + + Pressed and focused + + + Disabled + + , + ); + const hovered = page.getByRole('link', { name: 'Hovered' }); + const pressed = page.getByRole('link', { name: 'Pressed and focused' }); + const disabled = page.getByRole('link', { name: 'Disabled' }); + + await userEvent.hover(hovered); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.tab(); + await userEvent.keyboard('{Enter>}'); + + await expect.element(pressed).toHaveAttribute('data-pressed', 'true'); + await expect.element(pressed).toHaveAttribute('data-focus-visible', 'true'); + await expect.element(disabled).toBeVisible(); + await expect.element(disabled).toHaveStyle({ opacity: '1' }); + await captureVisual(scene, 'link/forced-colors-states'); + await userEvent.keyboard('{/Enter}'); + } finally { + await emulateForcedColors('none'); + } +}); + +async function emulateForcedColors(value: 'active' | 'none') { + await cdp().send('Emulation.setEmulatedMedia', { + features: [{ name: 'forced-colors', value }], + }); +} diff --git a/packages/@luke-ui/react/src/recipes/link.browser.test.ts b/packages/@luke-ui/react/src/recipes/link.browser.test.ts new file mode 100644 index 00000000..aed31538 --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/link.browser.test.ts @@ -0,0 +1,95 @@ +import '@luke-ui/react/themes/machined-edge.css'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { themeRootClassName } from '../theme/index.js'; +import { machinedEdgeThemeClassName } from '../themes/index.js'; +import { link } from './link.css.js'; + +let mounted: Array = []; + +afterEach(() => { + for (const element of mounted) element.remove(); + mounted = []; +}); + +test('defaults to the accent tone and keeps neutral distinct', () => { + const defaultLink = mountLink(); + const accent = mountLink({ tone: 'accent' }); + const neutral = mountLink({ tone: 'neutral' }); + + expect(getComputedStyle(defaultLink).color).toBe(getComputedStyle(accent).color); + expect(getComputedStyle(neutral).color).not.toBe(getComputedStyle(accent).color); +}); + +test('only standalone links provide a structural 24px target', () => { + const inline = mountLink(); + const standalone = mountLink({ isStandalone: true }); + + expect(getComputedStyle(inline).minBlockSize).toBe('0px'); + expect(getComputedStyle(inline).minInlineSize).toBe('0px'); + expect(getComputedStyle(standalone).minBlockSize).toBe('24px'); + expect(getComputedStyle(standalone).minInlineSize).toBe('24px'); +}); + +test.each(['accent', 'neutral'] as const)( + '%s hover and press change color and decoration without tactile travel', + (tone) => { + const resting = mountLink({ isStandalone: true, tone }); + const hovered = mountLink({ isStandalone: true, tone }); + hovered.dataset.hovered = 'true'; + const pressed = mountLink({ isStandalone: true, tone }); + pressed.dataset.pressed = 'true'; + + const restingStyle = getComputedStyle(resting); + const hoveredStyle = getComputedStyle(hovered); + const pressedStyle = getComputedStyle(pressed); + + expect(restingStyle.textDecorationLine).toBe('none'); + expect(hoveredStyle.color).not.toBe(restingStyle.color); + expect(hoveredStyle.textDecorationLine).toBe('underline'); + expect(pressedStyle.color).toBe(hoveredStyle.color); + expect(pressedStyle.textDecorationLine).toBe('underline'); + expect(hoveredStyle.boxShadow).toBe('none'); + expect(pressedStyle.boxShadow).toBe('none'); + expect(hoveredStyle.transform).toBe('none'); + expect(pressedStyle.transform).toBe('none'); + }, +); + +test.each(['accent', 'neutral'] as const)( + 'disabled %s links preserve their resting tone and ignore interaction states', + (tone) => { + const resting = mountLink({ isStandalone: true, tone }); + const disabled = mountLink({ isStandalone: true, tone }); + disabled.dataset.disabled = 'true'; + disabled.dataset.hovered = 'true'; + disabled.dataset.pressed = 'true'; + + expect(getComputedStyle(disabled).color).toBe(getComputedStyle(resting).color); + expect(getComputedStyle(disabled).textDecorationLine).toBe('none'); + expect(getComputedStyle(disabled).opacity).toBe('0.55'); + }, +); + +test('focus-visible shows the complete independent semantic ring', () => { + const focused = mountLink(); + focused.dataset.focusVisible = 'true'; + const style = getComputedStyle(focused); + + expect(style.outlineStyle).toBe('solid'); + expect(style.outlineWidth).toBe('2px'); + expect(style.outlineOffset).toBe('2px'); + expect(style.outlineColor).not.toBe('rgba(0, 0, 0, 0)'); +}); + +function mountLink(options: Parameters[0] = {}) { + const root = document.body.appendChild(document.createElement('div')); + root.className = `${themeRootClassName} ${machinedEdgeThemeClassName}`; + root.dataset.colorMode = 'light'; + const anchor = root.appendChild(document.createElement('a')); + anchor.className = link(options); + anchor.href = '#'; + anchor.textContent = 'Link'; + anchor.style.transition = 'none'; + mounted.push(root); + return anchor; +} diff --git a/packages/@luke-ui/react/src/recipes/link.css.ts b/packages/@luke-ui/react/src/recipes/link.css.ts index 23342d93..7dcb2310 100644 --- a/packages/@luke-ui/react/src/recipes/link.css.ts +++ b/packages/@luke-ui/react/src/recipes/link.css.ts @@ -1,15 +1,48 @@ import type { RecipeVariants } from '@vanilla-extract/recipes'; +import { focusRing } from '../styles/focus-ring.js'; import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; -import { vars } from '../styles/vars.css.js'; +import { vars } from '../theme/contract.css.js'; const base = styleInLayer('recipes', { - color: vars.themeColor.linkColor, + '@media': { + '(forced-colors: active)': { + color: 'LinkText', + forcedColorAdjust: 'auto', + selectors: { + '&[data-disabled="true"]': { + color: 'GrayText', + opacity: 1, + }, + '&[data-focus-visible="true"]': { + outlineColor: 'Highlight', + }, + }, + }, + '(prefers-reduced-motion: reduce)': { + transition: 'none', + }, + }, + color: vars.color.intent.accent.text, + cursor: 'pointer', font: 'inherit', + outlineColor: 'transparent', + outlineOffset: '2px', + outlineStyle: 'solid', + outlineWidth: '2px', textDecoration: 'underline', textDecorationColor: 'currentColor', transitionDuration: vars.motion.duration.fast, transitionProperty: 'color, text-decoration-color', transitionTimingFunction: vars.motion.easing.standard, + selectors: { + '&[data-disabled="true"]': { + cursor: 'not-allowed', + opacity: 0.55, + }, + '&[data-focus-visible="true"]': { + ...focusRing(vars.color.border.focus), + }, + }, }); /** Vanilla-extract recipe for the `Link` primitive's styles. */ @@ -17,14 +50,21 @@ export const link = recipeInLayer('recipes', { base, defaultVariants: { isStandalone: false, - tone: 'brand', + tone: 'accent', }, variants: { isStandalone: { false: {}, true: { + alignItems: 'center', + display: 'inline-flex', + minBlockSize: '24px', + minInlineSize: '24px', selectors: { - '&:enabled:hover': { + '&[data-hovered="true"]:not([data-disabled="true"])': { + textDecoration: 'underline', + }, + '&[data-pressed="true"]:not([data-disabled="true"])': { textDecoration: 'underline', }, }, @@ -32,22 +72,25 @@ export const link = recipeInLayer('recipes', { }, }, tone: { - brand: { - color: vars.themeColor.linkColor, + accent: { + color: vars.color.intent.accent.text, selectors: { - '&:enabled:hover': { - color: vars.themeColor.linkColorHover, + '&[data-hovered="true"]:not([data-disabled="true"])': { + color: vars.color.intent.accent.textHover, + }, + '&[data-pressed="true"]:not([data-disabled="true"])': { + color: vars.color.intent.accent.textHover, }, }, }, - inverted: { - color: vars.foregroundColor.inverse, - }, neutral: { - color: vars.foregroundColor.secondary, + color: vars.color.text.secondary, selectors: { - '&:enabled:hover': { - color: vars.foregroundColor.primary, + '&[data-hovered="true"]:not([data-disabled="true"])': { + color: vars.color.text.primary, + }, + '&[data-pressed="true"]:not([data-disabled="true"])': { + color: vars.color.text.primary, }, }, },