import React from "react"; import { StoryObj, Meta } from "@storybook/react-vite"; import { within, expect, userEvent, fn } from "storybook/test"; import { useRef, useEffect } from "react"; import Link, { IconLink, LinkButton } from "./link"; import type { LinkProps } from "./link.types"; import "../../styles/link/link.css"; const meta = { title: "FP.React Components/Links", tags: ["stable", "version:1.0.0", "autodocs"], component: Link, args: { href: "/", children: "Link", }, argTypes: { href: { control: "text", description: "The URL the link points to", }, target: { control: "select", options: ["_self", "_blank", "_parent", "_top"], description: "Where to open the linked URL", }, rel: { control: "text", description: "Relationship between current and linked document", }, prefetch: { control: "boolean", description: "Hint to browser to prefetch the resource", }, btnStyle: { control: "text", description: "Apply button styling to the link", }, }, parameters: { docs: { description: { component: `A semantic, accessible anchor component with enhanced security for external links and flexible styling variants. ## Features - **Automatic Security**: External links (\`target="_blank"\`) automatically get \`rel="noopener noreferrer"\` - **WCAG 2.1 AA Compliant**: Focus indicators, semantic HTML, keyboard navigation - **Flexible Styling**: Text links, button-styled links, and pill variants - **Performance**: Optional prefetch hints for faster navigation - **Ref Forwarding**: Direct DOM access for focus management - **Type-Safe**: Full TypeScript support with comprehensive prop types ## Exported Components ### Link (default) Main component for creating semantic anchor elements with enhanced features. ### IconLink Specialized component for icon-only or icon-with-text links. ### LinkButton Convenience component for button-styled links. ## CSS Variables All units use **rem** (not px). Base: 1rem = 16px. ### Typography & Color - \`--link-color\`: Link text color (default: #085ab7) - \`--link-fw\`: Font weight (default: 400) - \`--link-fs\`: Font size (default: 1rem) ### Text Decoration - \`--link-decoration\`: Text decoration style (default: none, underline on hover/focus) - \`--link-decoration-offset\`: Underline offset (default: 0.09375rem) - \`--link-decoration-thickness\`: Underline thickness (default: 0.0625rem) - \`--link-skip-ink\`: Text decoration skip ink (default: auto) ### Background & Border - \`--link-bg\`: Background color (default: transparent) - \`--link-radius\`: Border radius (default: 0.25rem, 99rem for pills) ### Spacing - \`--link-padding-inline\`: Horizontal padding (default: 0, calculated for button variants) - \`--link-padding-block\`: Vertical padding (default: 0, calculated for button variants) ### Focus Indicators (WCAG 2.4.7) - \`--link-focus-color\`: Focus outline color (default: currentColor) - \`--link-focus-width\`: Focus outline width (default: 0.125rem) - \`--link-focus-offset\`: Focus outline offset (default: 0.125rem) - \`--link-focus-style\`: Focus outline style (default: solid) ### Transitions - \`--link-transition\`: Transition timing (default: all 0.75s ease-in-out) ### Button Variant Variables Applied when using \`btnStyle\` prop, \`data-btn\` attribute, or \`\`/\`\` wrappers: - \`--link-button-color\`: Button link text color (default: var(--link-color)) - \`--link-border-width\`: Button border width (default: 0.125rem) - \`--link-border-color\`: Button border color (default: currentColor) - \`--link-border-style\`: Button border style (default: solid) ## Usage Patterns ### Button Styling (3 ways) 1. **btnStyle prop**: \`Sign Up\` 2. **Wrapper elements**: \`Sign Up\` (button) or \`Sign Up\` (pill) 3. **Direct attribute**: \`Sign Up\` `, }, }, }, } as Meta; export default meta; type Story = StoryObj; /** * Basic link component with default styling. * Links should have descriptive text that makes sense out of context. */ export const Default: Story = { args: { href: "/about", children: "About Us", }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Verify basic rendering expect(link).toBeInTheDocument(); expect(link).toHaveTextContent("About Us"); expect(link).toHaveAttribute("href", "/about"); // Verify accessibility expect(link).toBeVisible(); expect(link.tagName).toBe("A"); }, }; /** * External link with automatic security attributes. * The component automatically adds rel="noopener noreferrer" for target="_blank". */ export const ExternalLink: Story = { args: { href: "https://www.google.com", target: "_blank", children: "Visit Google", }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Verify external link attributes expect(link).toBeInTheDocument(); expect(link).toHaveTextContent("Visit Google"); expect(link).toHaveAttribute("href", "https://www.google.com"); expect(link).toHaveAttribute("target", "_blank"); // Verify automatic security attributes const rel = link.getAttribute("rel"); expect(rel).toContain("noopener"); expect(rel).toContain("noreferrer"); }, }; /** * External link with prefetch hint. * Combines security attributes with performance optimization. */ export const ExternalLinkWithPrefetch: Story = { args: { href: "https://example.com/next-page", target: "_blank", prefetch: true, children: "Prefetch Example", }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Verify prefetch is included with security const rel = link.getAttribute("rel"); expect(rel).toContain("noopener"); expect(rel).toContain("noreferrer"); expect(rel).toContain("prefetch"); }, }; /** * External link with custom rel attributes. * Custom rel values are merged with security defaults, not overwritten. */ export const ExternalLinkWithCustomRel: Story = { args: { href: "https://example.com", target: "_blank", rel: "nofollow author", children: "External Link with Custom Rel", }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Verify all rel tokens are present (merged, not replaced) const rel = link.getAttribute("rel"); expect(rel).toContain("noopener"); expect(rel).toContain("noreferrer"); expect(rel).toContain("nofollow"); expect(rel).toContain("author"); }, }; /** * Button-styled link using wrapper. * Maintains semantic element while applying button appearance. */ export const ButtonStyled: Story = { args: { href: "/signup", children: Sign Up Now, }, parameters: { docs: { description: { story: "Wrapping link text in `` automatically applies button styling. This is useful for call-to-action links.", }, }, }, }; /** * Pill-styled button link using wrapper. * Applies rounded pill styling with button appearance. */ export const PillStyled: Story = { args: { href: "/get-started", children: Get Started, }, parameters: { docs: { description: { story: "Wrapping link text in `` applies pill (fully rounded) button styling.", }, }, }, }; /** * Button-styled link with custom border radius. * Demonstrates CSS custom property override. */ export const ButtonWithCustomRadius: Story = { args: { href: "/action", children: Custom Radius, styles: { "--link-radius": "0.5rem", } as React.CSSProperties, }, }; /** * Link with custom color using CSS variables. * All styling is controlled via CSS custom properties. */ export const CustomColor: Story = { args: { href: "/products", children: "Browse Products", styles: { "--link-color": "#d63384", "--link-decoration": "underline", } as React.CSSProperties, }, }; /** * Keyboard navigation test. * Verifies link is keyboard accessible with proper focus indicators. */ export const KeyboardNavigation: Story = { args: { href: "/keyboard-test", children: "Keyboard Accessible Link", }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Tab to the link await userEvent.tab(); // Verify focus expect(link).toHaveFocus(); // Verify the link is announced by screen readers expect(link).toHaveAccessibleName("Keyboard Accessible Link"); }, }; /** * Icon-only link with aria-label. * Demonstrates accessible pattern for icon-only links. */ export const IconOnlyWithAriaLabel: Story = { args: { href: "/settings", "aria-label": "Open settings", children: ( ), }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Verify accessible name from aria-label expect(link).toHaveAccessibleName("Open settings"); // Verify SVG is hidden from screen readers const svg = link.querySelector("svg"); expect(svg).toHaveAttribute("aria-hidden", "true"); }, }; /** * Skip link pattern with ref forwarding. * Demonstrates programmatic focus management for accessibility. */ export const SkipLink: Story = { render: () => { const SkipLinkExample = () => { const mainRef = useRef(null); useEffect(() => { // Simulate focus on mount (for demonstration) const timer = setTimeout(() => { mainRef.current?.focus(); }, 100); return () => clearTimeout(timer); }, []); return (
Skip to main content

Main content starts here

); }; return ; }, parameters: { docs: { description: { story: "Demonstrates ref forwarding for programmatic focus management. Useful for skip links and keyboard navigation patterns.", }, }, }, }; /** * Email link (mailto:). * Demonstrates non-HTTP URL schemes. */ export const EmailLink: Story = { args: { href: "mailto:hello@example.com", children: "Contact us via email", }, }; /** * Phone link (tel:). * Demonstrates telephone URL scheme. */ export const PhoneLink: Story = { args: { href: "tel:+1234567890", children: "Call us: +1 (234) 567-890", }, }; /** * Link with onClick event handler (RECOMMENDED). * onClick fires for all activation methods including keyboard. */ export const WithOnClick: Story = { args: { href: "/products", children: "Track All Activations", onClick: fn(), }, parameters: { docs: { description: { story: "**Recommended**: Use `onClick` for analytics and tracking. It captures mouse clicks, touch events, AND keyboard activation (Enter key).", }, }, }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Click with mouse await userEvent.click(link); // Also works with keyboard link.focus(); await userEvent.keyboard("{Enter}"); // Check console for both events }, }; /** * Link with onPointerDown event handler. * Only fires for pointer events (mouse, touch, pen) - NOT keyboard. */ export const WithOnPointerDown: Story = { args: { href: "/products", children: "Track Pointer Only", onPointerDown: fn(), }, parameters: { docs: { description: { story: "⚠️ **Accessibility Note**: `onPointerDown` does NOT fire for keyboard activation (Enter key). Use `onClick` if you need to track keyboard users.", }, }, }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Click with mouse - handler fires await userEvent.click(link); // Keyboard activation - handler does NOT fire link.focus(); await userEvent.keyboard("{Enter}"); // Check console - only one event logged (mouse click) }, }; /** * Link with both onClick and onPointerDown. * Demonstrates using both handlers together for comprehensive tracking. */ export const WithBothHandlers: Story = { args: { href: "/products", children: "Track Both Ways", onClick: fn(), onPointerDown: fn(), }, parameters: { docs: { description: { story: "Use both handlers when you need comprehensive tracking: `onClick` captures all activations, while `onPointerDown` provides pointer-specific data.", }, }, }, }; /** * Button-styled link using btnStyle prop. * Demonstrates direct usage of the btnStyle prop instead of wrapper elements. */ export const WithBtnStyleProp: Story = { args: { href: "/dashboard", btnStyle: "btn", children: "Go to Dashboard", }, parameters: { docs: { description: { story: "Use the `btnStyle` prop to apply button styling directly. This sets the `data-btn` attribute on the link element.", }, }, }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Verify btnStyle creates data-btn attribute expect(link).toHaveAttribute("data-btn", "btn"); expect(link).toHaveTextContent("Go to Dashboard"); }, }; /** * IconLink component for icon-based navigation. * Specialized component for links with icons. */ export const IconLinkComponent: Story = { render: () => ( ), parameters: { docs: { description: { story: "The `IconLink` component is a convenience wrapper for icon-based links. Always include an `aria-label` for accessibility and set `aria-hidden='true'` on the icon.", }, }, }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Verify accessible name expect(link).toHaveAccessibleName("Return to homepage"); // Verify icon is hidden from screen readers const svg = link.querySelector("svg"); expect(svg).toHaveAttribute("aria-hidden", "true"); }, }; /** * IconLink with text label. * Icon link with visible text alongside the icon. */ export const IconLinkWithText: Story = { render: () => ( ), parameters: { docs: { description: { story: "IconLink can contain both an icon and text. The icon is passed via the `icon` prop, and text is provided as children.", }, }, }, }; /** * LinkButton component for call-to-action links. * Convenience component that applies button styling automatically. */ export const LinkButtonComponent: Story = { render: () => ( Get Started Free ), parameters: { docs: { description: { story: "The `LinkButton` component is a convenience wrapper for button-styled links. It maintains semantic `
` element while providing button appearance.", }, }, }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole("link"); // Verify it's still a semantic anchor expect(link.tagName).toBe("A"); expect(link).toHaveAttribute("href", "/get-started"); expect(link).toHaveTextContent("Get Started Free"); }, }; /** * LinkButton with custom styling. * Demonstrates CSS variable overrides on LinkButton. */ export const LinkButtonCustom: Story = { render: () => { const customStyles: React.CSSProperties = { "--link-button-color": "#ffffff", "--link-bg": "#0066cc", "--link-border-color": "#0066cc", "--link-radius": "0.5rem", } as React.CSSProperties; return ( Sign Up Now ); }, parameters: { docs: { description: { story: "LinkButton styling can be customized using CSS custom properties for colors, borders, and border radius.", }, }, }, };