Skip to main content
Glama
link.stories.tsx25.4 kB
import type { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, within } from '@storybook/test'; import { Home, Settings, User } from 'lucide-react'; import { Link, LinkColor, LinkUnderlined, LinkVariant } from './Link'; /** * Link Component Stories * * A versatile link component that handles both internal and external navigation * with comprehensive internationalization support and multiple visual variants. * * ## Key Features * - **Multiple Variants**: Default, invisible, button, outlined button, and hoverable styles * - **Color Themes**: Comprehensive color palette for different contexts and meanings * - **External Link Detection**: Automatic detection and handling of external URLs * - **Internationalization**: Built-in support for localized URLs via Intlayer * - **Security**: Automatic security attributes for external links * - **Accessibility**: Full ARIA support with proper labels and current page indication * - **Visual Feedback**: Hover effects, underline options, and active states * * ## When to Use * - Navigation within applications (internal links) * - External links to other websites with security measures * - Button-styled links for call-to-action scenarios * - Subtle hoverable links for navigation menus * - Multi-language website navigation with automatic URL localization */ const meta = { title: 'Components/Link', component: Link, parameters: { docs: { description: { component: ` A comprehensive link component that provides secure navigation with internationalization support and multiple visual styles. ### Navigation Types: - **Internal Links**: Application navigation with optional locale-aware URL generation - **External Links**: Secure external navigation with automatic security attributes - **Button Links**: Call-to-action links styled as buttons - **Navigation Links**: Subtle links for menus and navigation systems ### Security Features: External links automatically receive security attributes: - \`rel="noopener noreferrer nofollow"\` prevents security vulnerabilities - \`target="_blank"\` opens in new tab/window - Visual external link icon for user clarity ### Internationalization: When used with Intlayer, the component automatically: - Localizes internal URLs based on current or specified locale - Sets appropriate \`hrefLang\` attributes for SEO - Maintains proper URL structure for multi-language sites ### Accessibility Features: - **ARIA Labels**: Required labels for screen reader context - **Current Page**: \`aria-current\` support for active navigation states - **Keyboard Navigation**: Full keyboard support with focus indicators - **Screen Readers**: Proper announcements for external links and active states - **Focus Management**: Clear focus indicators with proper contrast ### Visual Variants: - **Default**: Standard underlined links with hover effects - **Invisible**: Clean links without underlines for subtle navigation - **Button**: Solid button styling for prominent call-to-action links - **Button Outlined**: Bordered button styling for secondary actions - **Hoverable**: Subtle background hover effects for menu items `, }, }, a11y: { config: { rules: [ { id: 'color-contrast', enabled: true, }, { id: 'link-name', enabled: true, }, ], }, }, }, tags: ['autodocs'], argTypes: { children: { description: 'Content displayed inside the link', control: 'text', }, href: { description: 'URL or path to navigate to', control: 'text', }, label: { description: "Accessible label describing the link's purpose (required)", control: 'text', }, variant: { description: 'Visual style variant of the link', control: 'select', options: Object.values(LinkVariant), }, color: { description: 'Color theme for the link', control: 'select', options: Object.values(LinkColor), }, underlined: { description: 'Underline visibility option', control: 'select', options: Object.values(LinkUnderlined), }, isExternalLink: { description: 'Override external link detection (auto-detected if undefined)', control: 'boolean', }, isActive: { description: 'Whether this link represents the current page', control: 'boolean', }, locale: { description: 'Locale for URL internationalization', control: 'select', options: ['en', 'fr', 'es', 'de'], }, className: { description: 'Additional CSS classes', control: 'text', }, }, } satisfies Meta<typeof Link>; export default meta; type Story = StoryObj<typeof Link>; /** * ## Basic Examples * * These stories demonstrate the core functionality and common usage patterns * of the Link component. */ /** * ### Default Link * * The basic link with default styling. Shows standard underline behavior * and hover effects for internal navigation. */ export const Default: Story = { args: { children: 'Default Link', href: '/example', label: 'Navigate to example page', variant: LinkVariant.DEFAULT, color: LinkColor.PRIMARY, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole('link', { name: /navigate to example page/i, }); // Test initial state await expect(link).toBeInTheDocument(); await expect(link).toHaveAttribute('href', '/example'); await expect(link).toHaveAttribute( 'aria-label', 'Navigate to example page' ); await expect(link).not.toHaveAttribute('target', '_blank'); // Test accessibility await expect(link).toHaveAccessibleName('Navigate to example page'); }, }; /** * ### External Link * * Link to an external website with automatic security attributes and visual indicator. */ export const External: Story = { args: { children: 'Visit Intlayer', href: 'https://intlayer.org', label: 'Visit Intlayer official website', variant: LinkVariant.DEFAULT, color: LinkColor.PRIMARY, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole('link', { name: /visit intlayer official website/i, }); // Test external link attributes await expect(link).toHaveAttribute('href', 'https://intlayer.org'); await expect(link).toHaveAttribute('target', '_blank'); await expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow'); // Test external link icon is present const externalIcon = canvas.getByRole('img', { hidden: true }); await expect(externalIcon).toBeInTheDocument(); }, }; /** * ### All Variants * * Showcase of all available link variants to demonstrate visual differences. */ export const AllVariants: Story = { render: () => ( <div className="space-y-4"> <div className="flex flex-wrap items-center gap-4"> <Link href="/default" variant={LinkVariant.DEFAULT} color={LinkColor.PRIMARY} label="Default variant link" > Default </Link> <Link href="/invisible" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.PRIMARY} label="Invisible variant link" > Invisible </Link> <Link href="/button" variant={LinkVariant.BUTTON} color={LinkColor.PRIMARY} label="Button variant link" > Button </Link> <Link href="/outlined" variant={LinkVariant.BUTTON_OUTLINED} color={LinkColor.PRIMARY} label="Outlined button variant link" > Outlined </Link> <Link href="/hoverable" variant={LinkVariant.HOVERABLE} color={LinkColor.PRIMARY} label="Hoverable variant link" > Hoverable </Link> </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const links = canvas.getAllByRole('link'); // Test all variants are present await expect(links).toHaveLength(5); // Test each variant has proper attributes for (const link of links) { await expect(link).toHaveAccessibleName(); await expect(link).toHaveAttribute('href'); await expect(link).not.toHaveAttribute('target', '_blank'); } }, }; /** * ### Color Themes * * Available color themes for different contexts and semantic meanings. */ export const ColorThemes: Story = { render: () => ( <div className="grid max-w-2xl grid-cols-2 gap-4"> {Object.values(LinkColor) .filter((color) => color !== LinkColor.CUSTOM) .map((color) => ( <div key={color} className="space-y-2"> <h4 className="font-medium text-sm capitalize"> {color.replace('_', ' ')} </h4> <div className="flex gap-3"> <Link href={`/${color}`} color={color} variant={LinkVariant.DEFAULT} label={`${color} default link`} > Default </Link> <Link href={`/${color}-button`} color={color} variant={LinkVariant.BUTTON} label={`${color} button link`} > Button </Link> </div> </div> ))} </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const links = canvas.getAllByRole('link'); // Should have 2 links per color (excluding CUSTOM) const colorCount = Object.values(LinkColor).filter( (c) => c !== LinkColor.CUSTOM ).length; const expectedCount = colorCount * 2; await expect(links).toHaveLength(expectedCount); // Test accessibility for (const link of links) { await expect(link).toHaveAccessibleName(); } }, }; /** * ## Interactive States * * Stories demonstrating different interactive states and behaviors. */ /** * ### Active/Current Page * * Link representing the current page with active state styling. */ export const ActiveState: Story = { args: { children: 'Current Page', href: '/current', label: 'Current page link', isActive: true, variant: LinkVariant.HOVERABLE, color: LinkColor.PRIMARY, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const link = canvas.getByRole('link', { name: /current page link/i }); // Test active state await expect(link).toHaveAttribute('aria-current', 'page'); await expect(link).toHaveAccessibleName('Current page link'); }, }; /** * ### Underline Variations * * Different underline styles for various design requirements. */ export const UnderlineVariations: Story = { render: () => ( <div className="space-y-4"> <div className="space-y-2"> <h4 className="font-medium">Default Underline Behavior</h4> <Link href="/default-underline" underlined={LinkUnderlined.DEFAULT} label="Default underline behavior" > Default (varies by variant) </Link> </div> <div className="space-y-2"> <h4 className="font-medium">Always Underlined</h4> <Link href="/always-underlined" underlined={LinkUnderlined.TRUE as any} variant={LinkVariant.INVISIBLE_LINK} label="Always underlined link" > Always underlined </Link> </div> <div className="space-y-2"> <h4 className="font-medium">Never Underlined</h4> <Link href="/never-underlined" underlined={LinkUnderlined.FALSE as any} variant={LinkVariant.DEFAULT} label="Never underlined link" > Never underlined </Link> </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const links = canvas.getAllByRole('link'); await expect(links).toHaveLength(3); // Test each link has proper accessibility for (const link of links) { await expect(link).toHaveAccessibleName(); await expect(link).toHaveAttribute('href'); } }, }; /** * ## Navigation Patterns * * Stories showing common navigation patterns and usage scenarios. */ /** * ### Navigation Menu * * Typical navigation menu with hoverable links and active state. */ export const NavigationMenu: Story = { render: () => ( <nav className="rounded-lg bg-gray-50 p-4"> <div className="flex space-x-6"> <Link href="/" variant={LinkVariant.HOVERABLE} color={LinkColor.TEXT} label="Go to home page" isActive={true} className="px-3 py-2" > <Home className="mr-2 inline h-4 w-4" /> Home </Link> <Link href="/dashboard" variant={LinkVariant.HOVERABLE} color={LinkColor.TEXT} label="Go to dashboard" className="px-3 py-2" > Dashboard </Link> <Link href="/profile" variant={LinkVariant.HOVERABLE} color={LinkColor.TEXT} label="Go to user profile" className="px-3 py-2" > <User className="mr-2 inline h-4 w-4" /> Profile </Link> <Link href="/settings" variant={LinkVariant.HOVERABLE} color={LinkColor.TEXT} label="Go to settings page" className="px-3 py-2" > <Settings className="mr-2 inline h-4 w-4" /> Settings </Link> </div> </nav> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const nav = canvas.getByRole('navigation'); const links = canvas.getAllByRole('link'); await expect(nav).toBeInTheDocument(); await expect(links).toHaveLength(4); // Test active link const homeLink = canvas.getByRole('link', { name: /go to home page/i }); await expect(homeLink).toHaveAttribute('aria-current', 'page'); // Test navigation interaction const dashboardLink = canvas.getByRole('link', { name: /go to dashboard/i, }); await userEvent.hover(dashboardLink); }, }; /** * ### Call-to-Action Links * * Button-styled links for prominent actions and conversions. */ export const CallToAction: Story = { render: () => ( <div className="max-w-md space-y-6 rounded-lg bg-gray-50 p-6"> <div className="space-y-4 text-center"> <h3 className="font-semibold text-xl">Ready to Get Started?</h3> <p className="text-gray-600"> Join thousands of users who trust our platform for their projects. </p> <div className="space-y-3"> <Link href="/signup" variant={LinkVariant.BUTTON} color={LinkColor.PRIMARY} label="Sign up for free account" className="w-full" > Start Free Trial </Link> <Link href="/demo" variant={LinkVariant.BUTTON_OUTLINED} color={LinkColor.PRIMARY} label="Watch product demo" className="w-full" > Watch Demo </Link> <Link href="/pricing" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.TEXT} label="View pricing information" className="text-sm" > View Pricing → </Link> </div> </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const links = canvas.getAllByRole('link'); await expect(links).toHaveLength(3); // Test CTA buttons const signupLink = canvas.getByRole('link', { name: /sign up for free account/i, }); const demoLink = canvas.getByRole('link', { name: /watch product demo/i }); await expect(signupLink).toBeInTheDocument(); await expect(demoLink).toBeInTheDocument(); // Test interaction await userEvent.hover(signupLink); }, }; /** * ### Footer Links * * Typical footer link patterns with external resources. */ export const FooterLinks: Story = { render: () => ( <footer className="rounded-lg bg-gray-900 p-6 text-white"> <div className="grid grid-cols-3 gap-6"> <div> <h4 className="mb-3 font-semibold">Company</h4> <div className="space-y-2"> <Link href="/about" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.LIGHT} label="Learn about our company" className="block text-sm" > About Us </Link> <Link href="/careers" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.LIGHT} label="View career opportunities" className="block text-sm" > Careers </Link> <Link href="/contact" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.LIGHT} label="Contact our team" className="block text-sm" > Contact </Link> </div> </div> <div> <h4 className="mb-3 font-semibold">Resources</h4> <div className="space-y-2"> <Link href="/docs" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.LIGHT} label="View documentation" className="block text-sm" > Documentation </Link> <Link href="https://github.com/intlayer/intlayer" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.LIGHT} label="View source code on GitHub" className="block text-sm" > GitHub </Link> <Link href="/api" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.LIGHT} label="API reference" className="block text-sm" > API Reference </Link> </div> </div> <div> <h4 className="mb-3 font-semibold">Legal</h4> <div className="space-y-2"> <Link href="/privacy" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.LIGHT} label="Privacy policy" className="block text-sm" > Privacy Policy </Link> <Link href="/terms" variant={LinkVariant.INVISIBLE_LINK} color={LinkColor.LIGHT} label="Terms of service" className="block text-sm" > Terms of Service </Link> </div> </div> </div> </footer> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const footer = canvas.getByRole('contentinfo'); const links = canvas.getAllByRole('link'); await expect(footer).toBeInTheDocument(); await expect(links.length).toBeGreaterThan(8); // Test external link (GitHub) const githubLink = canvas.getByRole('link', { name: /view source code on github/i, }); await expect(githubLink).toHaveAttribute('target', '_blank'); await expect(githubLink).toHaveAttribute( 'rel', 'noopener noreferrer nofollow' ); }, }; /** * ## Accessibility Testing * * Stories specifically designed to test and demonstrate accessibility features. */ /** * ### Keyboard Navigation * * Testing keyboard accessibility and focus management across multiple links. */ export const KeyboardNavigation: Story = { render: () => ( <div className="space-y-4"> <div className="mb-4 text-gray-600 text-sm"> Use Tab to navigate between links, Enter to activate them. </div> <div className="space-y-3"> <Link href="/first" label="First link in sequence" color={LinkColor.PRIMARY} > First Link </Link> <Link href="/second" variant={LinkVariant.BUTTON} color={LinkColor.SECONDARY} label="Second link in sequence" > Second Link (Button) </Link> <Link href="https://example.com" variant={LinkVariant.BUTTON_OUTLINED} color={LinkColor.PRIMARY} label="Third link - external" > External Link </Link> <Link href="/fourth" variant={LinkVariant.HOVERABLE} isActive={true} color={LinkColor.PRIMARY} label="Fourth link - current page" > Current Page </Link> </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const links = canvas.getAllByRole('link'); await expect(links).toHaveLength(4); // Test keyboard navigation const firstLink = links[0]; await userEvent.click(firstLink); await expect(firstLink).toHaveFocus(); // Tab through links await userEvent.keyboard('{Tab}'); await expect(links[1]).toHaveFocus(); await userEvent.keyboard('{Tab}'); await expect(links[2]).toHaveFocus(); // Test external link attributes const externalLink = links[2]; await expect(externalLink).toHaveAttribute('target', '_blank'); // Test active link const activeLink = links[3]; await expect(activeLink).toHaveAttribute('aria-current', 'page'); }, }; /** * ### Screen Reader Support * * Demonstrates proper ARIA attributes and announcements for assistive technologies. */ export const ScreenReaderSupport: Story = { render: () => ( <div className="space-y-6"> <div className="mb-4 text-gray-700 text-sm"> Screen readers will announce link purposes, external link warnings, and current page status appropriately. </div> <div className="space-y-4"> <div> <h4 className="mb-2 font-medium">Internal Navigation</h4> <Link href="/dashboard" label="Navigate to user dashboard to manage account settings" variant={LinkVariant.DEFAULT} > Dashboard </Link> </div> <div> <h4 className="mb-2 font-medium">External Resource</h4> <Link href="https://developer.mozilla.org" label="Visit MDN Web Docs - opens in new window" variant={LinkVariant.DEFAULT} > MDN Web Docs </Link> </div> <div> <h4 className="mb-2 font-medium">Current Page Indicator</h4> <Link href="/accessibility" label="Accessibility guide - you are currently on this page" isActive={true} variant={LinkVariant.HOVERABLE} > Accessibility Guide </Link> </div> <div> <h4 className="mb-2 font-medium">Action Links</h4> <div className="flex gap-3"> <Link href="/download" label="Download the application installer file" variant={LinkVariant.BUTTON} color={LinkColor.SUCCESS} > Download </Link> <Link href="/delete-account" label="Permanently delete your account - this action cannot be undone" variant={LinkVariant.BUTTON} color={LinkColor.ERROR} > Delete Account </Link> </div> </div> </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Test descriptive labels const dashboardLink = canvas.getByRole('link', { name: /navigate to user dashboard to manage account settings/i, }); await expect(dashboardLink).toBeInTheDocument(); // Test external link const mdnLink = canvas.getByRole('link', { name: /visit mdn web docs - opens in new window/i, }); await expect(mdnLink).toHaveAttribute('target', '_blank'); // Test current page const activeLink = canvas.getByRole('link', { name: /accessibility guide - you are currently on this page/i, }); await expect(activeLink).toHaveAttribute('aria-current', 'page'); // Test action buttons with clear intent const downloadLink = canvas.getByRole('link', { name: /download the application installer file/i, }); const deleteLink = canvas.getByRole('link', { name: /permanently delete your account - this action cannot be undone/i, }); await expect(downloadLink).toBeInTheDocument(); await expect(deleteLink).toBeInTheDocument(); }, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server