Skip to content

Commit

Permalink
feat(ui): adds many components to design system (#232)
Browse files Browse the repository at this point in the history
  • Loading branch information
ixahmedxi authored Jul 12, 2023
1 parent 0e80e7d commit 3e82237
Show file tree
Hide file tree
Showing 28 changed files with 781 additions and 5 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
}
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": true,
"source.organizeImports": true
},
"editor.formatOnSave": true,
"tailwindCSS.experimental.classRegex": [
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
},
"dependencies": {
"@fontsource-variable/inter": "^5.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-avatar": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.6",
"clsx": "^1.2.1",
"react": "18.2.0",
"react-dom": "18.2.0",
Expand Down
15 changes: 15 additions & 0 deletions packages/ui/src/aspect-ratio/aspect-ratio.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { render, screen } from '@noodle/test-utils/renderer';

import { AspectRatio } from '.';

describe('Aspect Ratio', () => {
it('should render children', () => {
render(
<AspectRatio ratio={16 / 9}>
<div>children</div>
</AspectRatio>,
);

expect(screen.getByText('children')).toBeInTheDocument();
});
});
40 changes: 40 additions & 0 deletions packages/ui/src/aspect-ratio/aspect-ratio.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { type FC } from 'react';
import type { Meta, StoryObj } from '@storybook/react';

import { AspectRatio } from '.';

const component: FC<{ ratio: number }> = ({ ratio }) => (
<div className="w-[450px] overflow-hidden">
<AspectRatio ratio={ratio}>
<img
src="https://source.unsplash.com/random/450x450"
className="object-cover"
alt="random"
/>
</AspectRatio>
</div>
);

export default {
title: 'UI / Aspect Ratio',
component,
args: {
ratio: 16 / 9,
},
} as Meta<typeof component>;

type Story = StoryObj<typeof component>;

export const _16_9: Story = {};

export const _1_1: Story = {
args: {
ratio: 1,
},
};

export const _4_3: Story = {
args: {
ratio: 4 / 3,
},
};
5 changes: 5 additions & 0 deletions packages/ui/src/aspect-ratio/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';

const AspectRatio = AspectRatioPrimitive.Root;

export { AspectRatio };
66 changes: 66 additions & 0 deletions packages/ui/src/avatar/avatar.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { render, screen } from '@noodle/test-utils/renderer';

import { Avatar, AvatarFallback, AvatarImage } from '.';

declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
Image: typeof Image;
}
}

class MockImage extends Image {
override set onload(value: (() => void) | null) {
super.onload = value;
}

override set src(value: string) {
super.src = value;
if (this.onload) {
this.onload();
}
}
}

describe('Avatar', () => {
let originalImage: typeof Image;

beforeAll(() => {
// Save the original Image object
originalImage = window.Image;

// Assign the mock Image
window.Image = MockImage as unknown as typeof Image;
});

afterAll(() => {
// Restore the original Image object after all tests in this block are done
window.Image = originalImage;
});

it('should render the image', () => {
render(
<Avatar>
<AvatarImage
src="https://avatars.githubusercontent.com/u/20271968?v=4"
alt="@ixahmedxi"
/>
</Avatar>,
);

expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://avatars.githubusercontent.com/u/20271968?v=4',
);
});

it('should render the fallback', () => {
render(
<Avatar>
<AvatarFallback>AE</AvatarFallback>
</Avatar>,
);

expect(screen.getByText('AE')).toBeInTheDocument();
});
});
36 changes: 36 additions & 0 deletions packages/ui/src/avatar/avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type FC } from 'react';
import type { Meta, StoryObj } from '@storybook/react';

import { Avatar, AvatarFallback, AvatarImage } from '.';

const component: FC<{ fallback: boolean }> = ({ fallback }) => {
return (
<Avatar>
{!fallback && (
<AvatarImage
src="https://avatars.githubusercontent.com/u/20271968?v=4"
alt="@ixahmedxi"
/>
)}
<AvatarFallback>AE</AvatarFallback>
</Avatar>
);
};

export default {
title: 'Design System / Avatar',
component,
args: {
fallback: false,
},
} as Meta<typeof component>;

type Story = StoryObj<typeof component>;

export const Image: Story = {};

export const Fallback: Story = {
args: {
fallback: true,
},
};
50 changes: 50 additions & 0 deletions packages/ui/src/avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef, ElementRef } from 'react';
import { Fallback, Image, Root } from '@radix-ui/react-avatar';

import { cn } from '../utils/cn';

export const Avatar = forwardRef<
ElementRef<typeof Root>,
ComponentPropsWithoutRef<typeof Root>
>(({ className, ...props }, ref) => (
<Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
));

Avatar.displayName = Root.displayName;

export const AvatarImage = forwardRef<
ElementRef<typeof Image>,
ComponentPropsWithoutRef<typeof Image>
>(({ className, ...props }, ref) => (
<Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));

AvatarImage.displayName = Image.displayName;

export const AvatarFallback = forwardRef<
ElementRef<typeof Fallback>,
ComponentPropsWithoutRef<typeof Fallback>
>(({ className, ...props }, ref) => (
<Fallback
ref={ref}
className={cn(
'from-primary-500 via-primary-800 to-primary-500 text-gray-12 flex h-full w-full items-center justify-center rounded-full bg-gradient-to-tr font-medium',
className,
)}
{...props}
/>
));

AvatarFallback.displayName = Fallback.displayName;
2 changes: 1 addition & 1 deletion packages/ui/src/brand/brand.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
import { Brand } from '.';

export default {
title: 'Design System / Brand',
title: 'UI / Brand',
component: Brand,
args: {
size: 100,
Expand Down
9 changes: 7 additions & 2 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
export { Brand } from './brand';
export { cn } from './utils/cn';
export * from './brand';
export * from './utils/cn';
export * from './typography';
export * from './aspect-ratio';
export * from './skeleton';
export * from './avatar';
export * from './tooltip';
19 changes: 19 additions & 0 deletions packages/ui/src/skeleton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type HTMLAttributes } from 'react';

import { cn } from '../utils/cn';

export const Skeleton = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => {
return (
<div
role="presentation"
className={cn(
'bg-gray-3 dark:bg-graydark-3 animate-pulse rounded-lg',
className,
)}
{...props}
/>
);
};
11 changes: 11 additions & 0 deletions packages/ui/src/skeleton/skeleton.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { render, screen } from '@noodle/test-utils/renderer';

import { Skeleton } from '.';

describe('Skeleton', () => {
it('should render the component', () => {
render(<Skeleton />);

expect(screen.getByRole('presentation')).toHaveClass('animate-pulse');
});
});
26 changes: 26 additions & 0 deletions packages/ui/src/skeleton/skeleton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/react';

import { Skeleton } from '.';

const component = () => {
return (
<div className="flex items-center space-x-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-4 w-[200px]" />
</div>
</div>
);
};

export default {
title: 'UI / Skeleton',
component,
} as Meta;

type Story = StoryObj;

export const Default: Story = {
name: 'Skeleton',
};
29 changes: 29 additions & 0 deletions packages/ui/src/tooltip/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';

import { cn } from '../utils/cn';

const TooltipProvider = TooltipPrimitive.Provider;

const Tooltip = TooltipPrimitive.Root;

const TooltipTrigger = TooltipPrimitive.Trigger;

const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-gray-3 dark:bg-graydark-3 border-gray-6 dark:border-graydark-6 text-gray-11 dark:text-graydark-11 z-50 overflow-hidden rounded-lg border px-2 py-1.5 text-sm',
className,
)}
{...props}
/>
));

TooltipContent.displayName = TooltipPrimitive.Content.displayName;

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
32 changes: 32 additions & 0 deletions packages/ui/src/tooltip/tooltip.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import userEvent from '@testing-library/user-event';

import { render, screen } from '@noodle/test-utils/renderer';

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '.';

describe('Tooltip', () => {
it('should render the tooltip content', async () => {
render(
<TooltipProvider>
<Tooltip>
<TooltipTrigger>Trigger Tooltip</TooltipTrigger>
<TooltipContent>
<p className="max-w-[40ch] text-center">
You can invoke the bold styling by clicking on this button or
using the CMD + B keyboard shortcut.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>,
);

await userEvent.hover(
screen.getByRole('button', { name: /trigger tooltip/i }),
);

const all = await screen.findAllByText(/invoke the bold styling/i);

// IDK why but it renders twice
expect(all).toHaveLength(2);
});
});
Loading

0 comments on commit 3e82237

Please sign in to comment.