Skip to content

Commit

Permalink
feat: APP-2733 - Implement Link component (#68)
Browse files Browse the repository at this point in the history
  • Loading branch information
thekidnamedkd authored Jan 29, 2024
1 parent 863706b commit 46b8f26
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `Link` component
- Handle size property on `Progress` component
- Implement `InputTime` component

Expand Down
2 changes: 2 additions & 0 deletions src/components/link/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Link } from './link';
export { ILinkProps, LinkVariant } from './link.api';
24 changes: 24 additions & 0 deletions src/components/link/link.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { AnchorHTMLAttributes } from 'react';
import { type IconType } from '../icon';

export type LinkVariant = 'primary' | 'neutral';

export interface ILinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
/**
* Variant of the link.
* @default 'primary'
*/
variant?: LinkVariant;
/**
* Icon displayed on the right side of the link. Accepts any icon from src/components/icon/iconList.ts.
*/
iconRight?: IconType;
/**
* Whether the link is disabled.
*/
disabled?: boolean;
/**
* Optional description text.
*/
description?: string;
}
32 changes: 32 additions & 0 deletions src/components/link/link.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Link } from '.';

const meta: Meta<typeof Link> = {
title: 'components/Link',
component: Link,
tags: ['autodocs'],
argTypes: {
href: { control: 'text' },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/ISSDryshtEpB7SUSdNqAcw/branch/jfKRr1V9evJUp1uBeyP3Zz/Aragon-ODS?type=design&node-id=7958%3A15661&mode=design&t=chS7QmnJNo46KjlP-1',
},
docs: {
description: {
component: 'Component will auto size text from **14px to 16px** above `md` breakpoint.',
},
},
},
};

type Story = StoryObj<typeof Link>;

export const Default: Story = {
args: {
children: 'Label',
},
};

export default meta;
36 changes: 36 additions & 0 deletions src/components/link/link.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Link, type ILinkProps } from '.';

describe('<Link /> component', () => {
const createTestComponent = (props?: Partial<ILinkProps>) => {
const completeProps: ILinkProps = { children: 'Default children', href: 'http://default.com', ...props };

return <Link {...completeProps} />;
};

it('renders correctly with minimum props', () => {
render(createTestComponent({ children: 'Example', href: 'http://example.com' }));
const linkElement = screen.getByRole('link', { name: 'Example' });
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', 'http://example.com');
});

it('applies correct classes based on disabled prop', () => {
const handleClick = jest.fn();
render(createTestComponent({ children: 'TEST', disabled: true, onClick: handleClick }));
// eslint-disable-next-line testing-library/no-node-access
const linkElement = screen.getByText('TEST').closest('a')!;
fireEvent.click(linkElement);
expect(linkElement).toHaveClass('truncate text-neutral-300 cursor-not-allowed');
expect(linkElement).toHaveAttribute('aria-disabled', 'true');
expect(linkElement).not.toHaveAttribute('href');
expect(handleClick).not.toHaveBeenCalled();
});

it('renders correctly with no icon', () => {
const children = 'Link without icon';
render(createTestComponent({ children, href: 'http://example.com' }));
const linkElement = screen.getByRole('link', { name: 'Link without icon' });
expect(linkElement).toHaveTextContent(children);
});
});
66 changes: 66 additions & 0 deletions src/components/link/link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import classNames from 'classnames';
import React from 'react';
import { Icon } from '../icon';
import type { ILinkProps, LinkVariant } from './link.api';

export const variantToLabelClassNames: Record<LinkVariant, string[]> = {
primary: [
'text-primary-400 cursor-pointer', // Default
'hover:text-primary-600', // Hover state
'active:text-primary-800', // Active state
],
neutral: [
'text-neutral-500 cursor-pointer', // Default
'hover:text-neutral-800', // Hover state
'active:text-neutral-800', // Active state
],
};

const disabledStyle = 'truncate text-neutral-300 cursor-not-allowed';

export const Link = React.forwardRef<HTMLAnchorElement, ILinkProps>(
(
{
children,
disabled = false,
variant = 'primary',
description,
href,
iconRight,
onClick,
className,
target,
...props
},
ref,
) => {
const linkClassName = classNames(
'inline-flex max-w-fit flex-col gap-y-0.5 truncate rounded text-sm leading-tight focus:outline-none focus-visible:ring focus-visible:ring-primary focus-visible:ring-offset md:gap-y-1 md:text-base',
className,
disabled ? disabledStyle : variantToLabelClassNames[variant],
);
const descriptionClassName = classNames('truncate', disabled ? disabledStyle : 'text-neutral-500');
const linkRel = target === '_blank' ? 'noopener noreferrer' : '';

return (
<a
ref={ref}
onClick={!disabled ? onClick : undefined}
href={disabled ? undefined : href}
className={linkClassName}
{...(disabled && { tabIndex: -1, 'aria-disabled': 'true' })}
target={target}
rel={linkRel}
{...props}
>
<div className="flex items-center gap-x-2 truncate">
{children}
{iconRight && <Icon icon={iconRight} size="sm" />}
</div>
{description && <p className={descriptionClassName}>{description}</p>}
</a>
);
},
);

Link.displayName = 'Link';

0 comments on commit 46b8f26

Please sign in to comment.