diff --git a/src/components/Typography/Text.spec.tsx b/src/components/Typography/Text.spec.tsx new file mode 100644 index 00000000..b4e3ac02 --- /dev/null +++ b/src/components/Typography/Text.spec.tsx @@ -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 by default with Small variant and default leading', () => { + render(Hello); + 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(Body); + 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( + + Small 150 + , + ); + const el = screen.getByText('Small 150'); + expect(el.className).toContain('leading-[150%]'); + }); + + test('respects component override to

', () => { + render(Paragraph); + const el = screen.getByText('Paragraph'); + expect(el.tagName.toLowerCase()).toBe('p'); + }); + + test('adds alignment utility class', () => { + render( + + Centered + , + ); + const el = screen.getByText('Centered'); + expect(el.className).toContain('text-center'); + }); + + test('applies color class from enum', () => { + render(Danger); + const el = screen.getByText('Danger'); + expect(el.className).toContain('text-error'); + }); + + test('merges custom cssClass', () => { + render(Decorated); + const el = screen.getByText('Decorated'); + expect(el.className).toContain('underline'); + }); + + test('applies bold font weight when bold is true', () => { + render(Bold Text); + const el = screen.getByText('Bold Text'); + expect(el.className).toContain('font-semibold'); + }); +}); diff --git a/src/components/Typography/Text.stories.tsx b/src/components/Typography/Text.stories.tsx new file mode 100644 index 00000000..7238bfb7 --- /dev/null +++ b/src/components/Typography/Text.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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

', + args: { component: 'p', children: 'Paragraph text' }, +}; + +export const Showcase: Story = { + render: () => ( +

+ + Body — 16/28 (default leading) + + + Body — 150% leading + + Small — 14/16 + + Small — 150% leading (≈14/21) + + Tiny — 12/14 + + Tiny — 150% leading (≈12/18) + + + Caption — 10/12 secondary + + Rendered as <label> + + Body bold + +
+ ), +}; + +export const TextVariants: Story = { + render: () => ( +
+ {Object.values(TextVariant).map((variant) => ( + + Variant: {variant} + + ))} +
+ ), +}; + +export const TextColors: Story = { + render: () => ( +
+ {Object.values(TextColor).map((color) => ( + + Color: {color} + + ))} +
+ ), +}; + +export const TextAlignments: Story = { + render: () => ( +
+ {Object.values(TextAlign).map((align) => ( + + {align} aligned text + + ))} +
+ ), +}; + +export const CustomClass: Story = { + name: 'With custom cssClass', + args: { + cssClass: 'underline decoration-dotted', + children: 'Underlined with dotted decoration', + }, +}; diff --git a/src/components/Typography/Text.tsx b/src/components/Typography/Text.tsx new file mode 100644 index 00000000..f7147775 --- /dev/null +++ b/src/components/Typography/Text.tsx @@ -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 + * Body text + * Paragraph small text + * Small with 150% LH + * ``` + * + * @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 = ({ + 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 ( + + {children} + + ); +}; diff --git a/src/components/Typography/Title.spec.tsx b/src/components/Typography/Title.spec.tsx new file mode 100644 index 00000000..71be4995 --- /dev/null +++ b/src/components/Typography/Title.spec.tsx @@ -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

with base classes and default leading', () => { + render(Level 1); + 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

with correct weight and leading', () => { + render(Level 2); + 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

with correct size/leading', () => { + render(Level 3); + 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(Danger); + const el = screen.getByText('Danger'); + expect(el.className).toContain('text-error'); + }); + + test('applies alignment utility class', () => { + render( + + Centered Heading + , + ); + const el = screen.getByText('Centered Heading'); + expect(el.className).toContain('text-center'); + }); + + test('merges custom cssClass', () => { + render( + Decorated, + ); + const el = screen.getByText('Decorated'); + expect(el.className).toContain('underline'); + expect(el.className).toContain('decoration-dotted'); + }); + + test('sets id attribute when provided', () => { + render(With ID); + const el = screen.getByText('With ID'); + expect(el).toHaveAttribute('id', 'title-id'); + }); +}); diff --git a/src/components/Typography/Title.stories.tsx b/src/components/Typography/Title.stories.tsx new file mode 100644 index 00000000..6d4816b0 --- /dev/null +++ b/src/components/Typography/Title.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { DialTitle, type DialTitleProps } from './Title'; +import { TextColor, TextAlign } from '@/types/typography'; + +const meta = { + title: 'Components/Typography/Title', + component: DialTitle, + parameters: { layout: 'centered' }, + argTypes: { + level: { control: { type: 'radio' }, options: [1, 2, 3] }, + color: { control: { type: 'select' }, options: Object.values(TextColor) }, + align: { control: { type: 'select' }, options: Object.values(TextAlign) }, + cssClass: { control: { type: 'text' } }, + children: { control: { type: 'text' } }, + }, + args: { + level: 1, + color: TextColor.Primary, + children: 'Heading', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Level1: Story = { + args: { level: 1, children: 'Level 1 — 20/24 semibold' }, +}; + +export const Level2: Story = { + args: { level: 2, children: 'Level 2 — 20/24 normal' }, +}; + +export const Level3: Story = { + args: { level: 3, children: 'Level 3 — 16/18 semibold' }, +}; + +export const CenteredError: Story = { + name: 'Centered • Error', + args: { + level: 2, + align: TextAlign.Center, + color: TextColor.Error, + children: 'Important notice', + }, +}; + +export const Showcase: Story = { + render: () => ( +
+ Level 1 — default + + Level 2 — secondary + + + Level 3 — accent primary + + + Right aligned + + + Custom class (underline dotted) + +
+ ), +}; + +export const TitleLevels: Story = { + render: () => ( +
+ {[1, 2, 3].map((level) => ( + + Level {level} + + ))} +
+ ), +}; + +export const TitleColors: Story = { + render: () => ( +
+ {Object.values(TextColor).map((color) => ( + + Color: {color} + + ))} +
+ ), +}; + +export const TitleAlignments: Story = { + render: () => ( +
+ {Object.values(TextAlign).map((align) => ( + + {align} aligned title + + ))} +
+ ), +}; + +export const CustomClass: Story = { + name: 'With custom cssClass', + args: { + cssClass: 'underline decoration-dotted', + children: 'Underlined with dotted decoration', + }, +}; diff --git a/src/components/Typography/Title.tsx b/src/components/Typography/Title.tsx new file mode 100644 index 00000000..56b5b460 --- /dev/null +++ b/src/components/Typography/Title.tsx @@ -0,0 +1,60 @@ +import type { FC } from 'react'; +import { TextColor, type DialTypographyBaseProps } from '@/types/typography'; +import { + alignClassMap, + defaultTitleTagByLevel, + textColors, + titleLevelClassMap, +} from './constants'; +import { mergeClasses } from '@/utils/merge-classes'; + +export interface DialTitleProps extends DialTypographyBaseProps { + level?: 1 | 2 | 3; +} + +/** + * Title with levels (1–3). + * + * Uses Tailwind utility classes sourced from constants and supports: + * - color, alignment, custom classes via shared base props + * + * @example + * ```tsx + * Dashboard + * Section + * Subsection + * ``` + * + * @param [level=1] - Visual level (1–3) + * @param [color=TextColor.Primary] - Text color token (utility class) + * @param [align] - Text alignment + * @param [cssClass] - Additional utility classes + * @param [id] - Optional id attribute + * @param children - Title text + */ +export const DialTitle: FC = ({ + level = 1, + color = TextColor.Primary, + align, + cssClass, + id, + children, +}) => { + const Tag = defaultTitleTagByLevel[level]; + + return ( + + {children} + + ); +}; + +export default DialTitle; diff --git a/src/components/Typography/constants.ts b/src/components/Typography/constants.ts new file mode 100644 index 00000000..9b4f319a --- /dev/null +++ b/src/components/Typography/constants.ts @@ -0,0 +1,49 @@ +import { TextAlign, TextColor, TextVariant } from '@/types/typography'; + +export const textVariantClassMap: Record = { + body: 'text-[16px]', + small: 'text-[14px]', + tiny: 'text-[12px]', + caption: 'text-[10px]', +}; + +export const textDefaultLeadingClassMap: Record = { + [TextVariant.Body]: 'leading-[28px]', + [TextVariant.Small]: 'leading-[16px]', + [TextVariant.Tiny]: 'leading-[14px]', + [TextVariant.Caption]: 'leading-[12px]', +}; + +export const textColors: Record = { + [TextColor.Transparent]: 'text-transparent', + [TextColor.Primary]: 'text-primary', + [TextColor.Secondary]: 'text-secondary', + [TextColor.Error]: 'text-error', + [TextColor.Warning]: 'text-warning', + [TextColor.Info]: 'text-info', + [TextColor.Success]: 'text-success', + [TextColor.White]: 'text-white', + [TextColor.AccentPrimary]: 'text-accent-primary', + [TextColor.AccentSecondary]: 'text-accent-secondary', + [TextColor.AccentTertiary]: 'text-accent-tertiary', + [TextColor.ControlsPermanent]: 'text-controls-permanent', + [TextColor.ControlsTemporary]: 'text-controls-temporary', +}; + +export const alignClassMap: Record = { + left: 'text-left', + center: 'text-center', + right: 'text-right', +}; + +export const defaultTitleTagByLevel = { + 1: 'h1', + 2: 'h2', + 3: 'h3', +} as const; + +export const titleLevelClassMap = { + 1: 'text-[20px] font-semibold leading-[24px]', + 2: 'text-[20px] font-normal leading-[24px]', + 3: 'text-[16px] font-semibold leading-[18px]', +} as const; diff --git a/src/components/Typography/index.ts b/src/components/Typography/index.ts new file mode 100644 index 00000000..f5fa8996 --- /dev/null +++ b/src/components/Typography/index.ts @@ -0,0 +1,7 @@ +import { DialText } from './Text'; +import { DialTitle } from './Title'; + +export const DialTypography = { + Text: DialText, + Title: DialTitle, +}; diff --git a/src/index.ts b/src/index.ts index 5ba7bc9d..2aa2ce6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export { DialTag } from './components/Tag/Tag'; export { DialEllipsisTooltip } from './components/EllipsisTooltip/EllipsisTooltip'; export { DialTabs } from './components/Tabs/Tabs'; export { DialTab } from './components/Tab/Tab'; +export { DialTypography } from './components/Typography'; // Buttons export { DialButton } from './components/Button/Button'; diff --git a/src/types/typography.ts b/src/types/typography.ts new file mode 100644 index 00000000..d04ecbcc --- /dev/null +++ b/src/types/typography.ts @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react'; + +export interface DialTypographyBaseProps { + color?: TextColor; + lineHeight150?: boolean; + align?: TextAlign; + cssClass?: string; + children: ReactNode; + id?: string; +} + +export enum TextVariant { + Body = 'body', + Small = 'small', + Tiny = 'tiny', + Caption = 'caption', +} + +export enum TextColor { + Primary = 'primary', + Secondary = 'secondary', + Error = 'error', + Transparent = 'transparent', + Warning = 'warning', + Success = 'success', + White = 'white', + AccentPrimary = 'accent-primary', + AccentSecondary = 'accent-secondary', + AccentTertiary = 'accent-tertiary', + ControlsPermanent = 'controls-permanent', + ControlsTemporary = 'controls-temporary', + Info = 'info', +} + +export enum TextAlign { + Left = 'left', + Center = 'center', + Right = 'right', +}