Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/components/Typography/Text.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { DialText } from './Text';
import { TextAlign, TextColor, TextVariant } from '@/types/typography';

describe('Dial UI Kit :: DialText', () => {
test('renders as <span> by default with Small variant and default leading', () => {
render(<DialText>Hello</DialText>);
const el = screen.getByText('Hello');
expect(el.tagName.toLowerCase()).toBe('span');
expect(el.className).toContain('text-[14px]');
expect(el.className).toContain('leading-[16px]');
expect(el.className).toContain('font-normal');
expect(el.className).toContain('text-primary');
});

test('applies Body variant size and leading', () => {
render(<DialText variant={TextVariant.Body}>Body</DialText>);
const el = screen.getByText('Body');
expect(el.className).toContain('text-[16px]');
expect(el.className).toContain('leading-[28px]');
});

test('applies 150% line-height when lineHeight150 is true', () => {
render(
<DialText variant={TextVariant.Small} lineHeight150>
Small 150
</DialText>,
);
const el = screen.getByText('Small 150');
expect(el.className).toContain('leading-[150%]');
});

test('respects component override to <p>', () => {
render(<DialText component="p">Paragraph</DialText>);
const el = screen.getByText('Paragraph');
expect(el.tagName.toLowerCase()).toBe('p');
});

test('adds alignment utility class', () => {
render(
<DialText align={TextAlign.Center} variant={TextVariant.Tiny}>
Centered
</DialText>,
);
const el = screen.getByText('Centered');
expect(el.className).toContain('text-center');
});

test('applies color class from enum', () => {
render(<DialText color={TextColor.Error}>Danger</DialText>);
const el = screen.getByText('Danger');
expect(el.className).toContain('text-error');
});

test('merges custom cssClass', () => {
render(<DialText cssClass="underline">Decorated</DialText>);
const el = screen.getByText('Decorated');
expect(el.className).toContain('underline');
});

test('applies bold font weight when bold is true', () => {
render(<DialText bold>Bold Text</DialText>);
const el = screen.getByText('Bold Text');
expect(el.className).toContain('font-semibold');
});
});
147 changes: 147 additions & 0 deletions src/components/Typography/Text.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { DialText, type DialTextProps } from './Text';
import { TextVariant, TextColor, TextAlign } from '@/types/typography';

const meta = {
title: 'Components/Typography/Text',
component: DialText,
parameters: { layout: 'centered' },
argTypes: {
variant: {
control: { type: 'select' },
options: Object.values(TextVariant),
},
color: { control: { type: 'select' }, options: Object.values(TextColor) },
align: { control: { type: 'select' }, options: Object.values(TextAlign) },
component: {
control: { type: 'select' },
options: [
'span',
'p',
'div',
'label',
'strong',
'em',
'code',
'blockquote',
'li',
],
},
cssClass: { control: { type: 'text' } },
lineHeight150: { control: { type: 'boolean' } },
bold: { control: { type: 'boolean' } },
children: { control: { type: 'text' } },
},
args: {
variant: TextVariant.Small,
color: TextColor.Primary,
component: 'span',
lineHeight150: false,
children: 'Sample text',
},
} satisfies Meta<DialTextProps>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Body: Story = {
args: { variant: TextVariant.Body, children: 'Body — 16/28' },
};

export const Tiny150: Story = {
name: 'Tiny • 150% line-height',
args: {
variant: TextVariant.Tiny,
lineHeight150: true,
children: 'Tiny with 150% LH (was 12/18)',
},
};

export const CaptionErrorCentered: Story = {
name: 'Caption • Error • Centered',
args: {
variant: TextVariant.Caption,
color: TextColor.Error,
align: TextAlign.Center,
children: 'Caption in error color and centered',
},
};

export const AsParagraph: Story = {
name: 'Rendered as <p>',
args: { component: 'p', children: 'Paragraph text' },
};

export const Showcase: Story = {
render: () => (
<div className="gap-2 flex flex-col">
<DialText variant={TextVariant.Body}>
Body — 16/28 (default leading)
</DialText>
<DialText variant={TextVariant.Body} lineHeight150>
Body — 150% leading
</DialText>
<DialText variant={TextVariant.Small}>Small — 14/16</DialText>
<DialText variant={TextVariant.Small} lineHeight150>
Small — 150% leading (≈14/21)
</DialText>
<DialText variant={TextVariant.Tiny}>Tiny — 12/14</DialText>
<DialText variant={TextVariant.Tiny} lineHeight150>
Tiny — 150% leading (≈12/18)
</DialText>
<DialText variant={TextVariant.Caption} color={TextColor.Secondary}>
Caption — 10/12 secondary
</DialText>
<DialText component="label">Rendered as &lt;label&gt;</DialText>
<DialText variant={TextVariant.Body} bold>
Body bold
</DialText>
</div>
),
};

export const TextVariants: Story = {
render: () => (
<div className="gap-4 flex flex-col">
{Object.values(TextVariant).map((variant) => (
<DialText key={variant} variant={variant}>
Variant: {variant}
</DialText>
))}
</div>
),
};

export const TextColors: Story = {
render: () => (
<div className="gap-4 flex flex-col">
{Object.values(TextColor).map((color) => (
<DialText key={color} color={color}>
Color: {color}
</DialText>
))}
</div>
),
};

export const TextAlignments: Story = {
render: () => (
<div className="flex flex-col gap-4 w-[300px]">
{Object.values(TextAlign).map((align) => (
<DialText key={align} align={align} variant={TextVariant.Body}>
{align} aligned text
</DialText>
))}
</div>
),
};

export const CustomClass: Story = {
name: 'With custom cssClass',
args: {
cssClass: 'underline decoration-dotted',
children: 'Underlined with dotted decoration',
},
};
73 changes: 73 additions & 0 deletions src/components/Typography/Text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
TextColor,
type DialTypographyBaseProps,
TextVariant,
} from '@/types/typography';
import type { ElementType, FC } from 'react';
import {
alignClassMap,
textColors,
textDefaultLeadingClassMap,
textVariantClassMap,
} from './constants';
import { mergeClasses } from '@/utils/merge-classes';

export interface DialTextProps extends DialTypographyBaseProps {
variant?: TextVariant;
component?: ElementType;
bold?: boolean;
}

/**
* Text component.
*
* Use `component` to render any tag (e.g., 'span', 'p', 'label', custom component).
* Toggle `lineHeight150` to set line-height to 150%.
*
* @example
* ```tsx
* <DialText>Body text</DialText>
* <DialText component="p" variant={TextVariant.Small} color={TextColor.Secondary}>Paragraph small text</DialText>
* <DialText variant={TextVariant.Small} lineHeight150>Small with 150% LH</DialText>
* ```
*
* @param [variant=TextVariant.Body] - Visual text style
* @param [color=TextColor.Primary] - Text color token (CSS variable powered)
* @param [align] - Text alignment
* @param [component='span'] - Rendered tag/component
* @param [lineHeight150=false] - When true, applies `line-height: 150%`
* @param [cssClass] - Additional utility classes for the container
* @param [bold=false] - When true, applies `font-weight: 600`
* @param children - Text content
*/
export const DialText: FC<DialTextProps> = ({
variant = TextVariant.Small,
color = TextColor.Primary,
align,
component = 'span',
cssClass,
lineHeight150 = false,
children,
bold = false,
}) => {
const Tag = component;
const leading = lineHeight150
? 'leading-[150%]'
: textDefaultLeadingClassMap[variant];

return (
<Tag
className={mergeClasses(
'font-normal',
textVariantClassMap[variant],
textColors[color],
leading,
align ? alignClassMap[align] : undefined,
bold ? 'font-semibold' : undefined,
cssClass,
)}
>
{children}
</Tag>
);
};
65 changes: 65 additions & 0 deletions src/components/Typography/Title.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { DialTitle } from './Title';
import { TextAlign, TextColor } from '@/types/typography';

describe('Dial UI Kit :: Title', () => {
test('renders level 1 as <h1> with base classes and default leading', () => {
render(<DialTitle>Level 1</DialTitle>);
const h = screen.getByRole('heading', { level: 1 });
expect(h).toBeInTheDocument();
expect(h.tagName.toLowerCase()).toBe('h1');
expect(h.className).toContain('text-[20px]');
expect(h.className).toContain('font-semibold');
expect(h.className).toContain('leading-[24px]');
});

test('renders level 2 as <h2> with correct weight and leading', () => {
render(<DialTitle level={2}>Level 2</DialTitle>);
const h = screen.getByRole('heading', { level: 2 });
expect(h.tagName.toLowerCase()).toBe('h2');
expect(h.className).toContain('text-[20px]');
expect(h.className).toContain('font-normal');
expect(h.className).toContain('leading-[24px]');
});

test('renders level 3 as <h3> with correct size/leading', () => {
render(<DialTitle level={3}>Level 3</DialTitle>);
const h = screen.getByRole('heading', { level: 3 });
expect(h.tagName.toLowerCase()).toBe('h3');
expect(h.className).toContain('text-[16px]');
expect(h.className).toContain('font-semibold');
expect(h.className).toContain('leading-[18px]');
});

test('applies color utility class from enum', () => {
render(<DialTitle color={TextColor.Error}>Danger</DialTitle>);
const el = screen.getByText('Danger');
expect(el.className).toContain('text-error');
});

test('applies alignment utility class', () => {
render(
<DialTitle level={2} align={TextAlign.Center}>
Centered Heading
</DialTitle>,
);
const el = screen.getByText('Centered Heading');
expect(el.className).toContain('text-center');
});

test('merges custom cssClass', () => {
render(
<DialTitle cssClass="underline decoration-dotted">Decorated</DialTitle>,
);
const el = screen.getByText('Decorated');
expect(el.className).toContain('underline');
expect(el.className).toContain('decoration-dotted');
});

test('sets id attribute when provided', () => {
render(<DialTitle id="title-id">With ID</DialTitle>);
const el = screen.getByText('With ID');
expect(el).toHaveAttribute('id', 'title-id');
});
});
Loading