feat(Link): create reusable Link ui component
This commit is contained in:
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user