feat(Link): create reusable Link ui component

This commit is contained in:
Ilia Mashkov
2026-04-23 13:05:22 +03:00
parent 4eafb96d35
commit 877719f106
3 changed files with 228 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
<script module lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { defineMeta } from '@storybook/addon-svelte-csf';
import type { ComponentProps } from 'svelte';
import Link from './Link.svelte';
const { Story } = defineMeta({
title: 'Shared/Link',
component: Link,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Styled link component based on the footer link design. Supports optional icon snippet and standard anchor attributes.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
href: {
control: 'text',
description: 'Link URL',
},
},
});
</script>
<Story
name="Default"
args={{
href: 'https://fonts.google.com',
target: '_blank',
}}
>
{#snippet template(args: ComponentProps<typeof Link>)}
<Link {...args}>
<span>Google Fonts</span>
</Link>
{/snippet}
</Story>
<Story
name="With Icon"
args={{
href: 'https://fonts.google.com',
target: '_blank',
}}
>
{#snippet template(args: ComponentProps<typeof Link>)}
<Link {...args}>
<span>Google Fonts</span>
{#snippet icon()}
<ArrowUpRightIcon
size={10}
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
/>
{/snippet}
</Link>
{/snippet}
</Story>
<Story name="Multiple Links">
{#snippet template()}
<div class="flex gap-4 p-8 bg-neutral-100 dark:bg-neutral-900 rounded-lg">
<Link href="https://fonts.google.com" target="_blank">
<span>Google Fonts</span>
{#snippet icon()}
<ArrowUpRightIcon
size={10}
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
/>
{/snippet}
</Link>
<Link href="https://www.fontshare.com" target="_blank">
<span>Fontshare</span>
{#snippet icon()}
<ArrowUpRightIcon
size={10}
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
/>
{/snippet}
</Link>
<Link href="https://github.com" target="_blank">
<span>GitHub</span>
{#snippet icon()}
<ArrowUpRightIcon
size={10}
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
/>
{/snippet}
</Link>
</div>
{/snippet}
</Story>
+45
View File
@@ -0,0 +1,45 @@
<!--
Component: Link
A styled link component based on the footer link design.
Supports optional icon snippet and standard anchor attributes.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements';
interface Props extends HTMLAnchorAttributes {
/**
* Link content
*/
children?: Snippet;
/**
* Optional icon snippet
*/
icon?: Snippet;
/**
* CSS classes
*/
class?: string;
}
let {
children,
icon,
class: className,
...rest
}: Props = $props();
</script>
<a
class={cn(
'group inline-flex items-center gap-1 text-2xs font-mono uppercase tracking-wider-mono',
'text-neutral-400 hover:text-brand transition-colors',
'bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 py-1 pointer-events-auto',
className,
)}
{...rest}
>
{@render children?.()}
{@render icon?.()}
</a>
+87
View File
@@ -0,0 +1,87 @@
import {
render,
screen,
} from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import Link from './Link.svelte';
/**
* Helper to create a plain text snippet
*/
function textSnippet(text: string) {
return createRawSnippet(() => ({
render: () => `<span>${text}</span>`,
}));
}
/**
* Helper to create an icon snippet
*/
function iconSnippet() {
return createRawSnippet(() => ({
render: () => `<svg class="lucide-arrow-up-right"></svg>`,
}));
}
describe('Link', () => {
const defaultProps = {
href: 'https://fonts.google.com',
};
describe('Rendering', () => {
it('renders text content via children snippet', () => {
render(Link, {
props: {
...defaultProps,
children: textSnippet('Google Fonts'),
},
});
expect(screen.getByText('Google Fonts')).toBeInTheDocument();
});
it('renders as an anchor element with correct href', () => {
render(Link, { props: defaultProps });
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://fonts.google.com');
});
it('renders the icon when provided via snippet', () => {
const { container } = render(Link, {
props: {
...defaultProps,
children: textSnippet('Google Fonts'),
icon: iconSnippet(),
},
});
const icon = container.querySelector('svg');
expect(icon).toBeInTheDocument();
expect(icon).toHaveClass('lucide-arrow-up-right');
});
});
describe('Attributes', () => {
it('applies custom CSS classes', () => {
render(Link, {
props: {
...defaultProps,
class: 'custom-class',
},
});
expect(screen.getByRole('link')).toHaveClass('custom-class');
});
it('spreads additional anchor attributes', () => {
render(Link, {
props: {
...defaultProps,
target: '_blank',
rel: 'noopener',
},
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener');
});
});
});