/** * Typography — semantic text presets, and the marks that go on them. * * The type scale in one place, so headings stay consistent instead of being * rebuilt out of size and weight classes at each call site. Built on the Text * primitive, so every preset keeps `className` passthrough and theme colours. * * ```tsx * Billing * Your plan renews on the 1st. * Terms of service * ``` * * A preset sets size, weight and tracking together; the marks — `underline`, * `italic`, `strike` — and `weight`, `align` and `transform` layer on top of * whichever preset is in force. They are props rather than class names because * the point of this component is that a screen never has to know which * utilities add up to "a bolded lead paragraph". * * React Native draws no list markers and has no blockquote, so * `Typography.List` and `Typography.Blockquote` build both out of a row and a * rule — which is exactly the kind of thing this component exists to stop * people rebuilding per screen. */ import { Children, forwardRef, type ReactNode } from 'react'; import { View, type Text as RNText, type ViewProps } from 'react-native'; import { tv, type VariantProps } from 'tailwind-variants'; import { Text, type TextProps, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; const typographyVariants = tv({ base: 'text-foreground', variants: { type: { h1: 'text-4xl font-bold tracking-tight', h2: 'text-3xl font-semibold tracking-tight', h3: 'text-2xl font-semibold tracking-tight', h4: 'text-xl font-semibold', h5: 'text-lg font-semibold', h6: 'text-base font-semibold', /** The sentence under a heading, set larger and quieter than body. */ lead: 'text-xl font-normal text-muted-foreground', body: 'text-base font-normal', 'body-sm': 'text-sm font-normal', 'body-xs': 'text-xs font-normal', /** Body, one step up — for a number or a name that carries the row. */ large: 'text-lg font-semibold', /* * Body, one step down and tighter — captions, footnotes, meta. * * Tight, but not tighter than the glyphs. A line height equal to the * font size leaves nothing below the baseline, and Android clips what * does not fit: `g`, `j`, `p`, `q` and `y` lose their tails. iOS draws * them anyway, which is why this survived — it is a bug on both, and * only one of them shows it. */ small: 'text-sm font-medium leading-tight', blockquote: 'text-base font-normal italic', code: 'font-mono text-sm text-foreground', }, weight: { normal: 'font-normal', medium: 'font-medium', semibold: 'font-semibold', bold: 'font-bold', }, align: { // `start` and `end` follow the reading direction; `left` and `right` // are the physical edges, for the rare line that has to stay put // whichever way the script runs — a figure in a table, a code caption. start: 'text-start', end: 'text-end', left: 'text-left', center: 'text-center', right: 'text-right', }, transform: { uppercase: 'uppercase', lowercase: 'lowercase', capitalize: 'capitalize', }, underline: { true: 'underline', }, italic: { true: 'italic', }, strike: { true: 'line-through', }, muted: { true: 'text-muted-foreground', }, }, defaultVariants: { type: 'body', }, }); export type TypographyType = NonNullable< VariantProps['type'] >; export type TypographyWeight = NonNullable< VariantProps['weight'] >; export interface TypographyProps extends Omit, VariantProps { className?: string; /** * Overrides the weight the preset sets. This is the one to reach for when a * paragraph needs a bolded run and a heading would be wrong. */ weight?: TypographyWeight; /** Underlines the text — a link, a defined term, a signature line. */ underline?: boolean; /** Slants the text. */ italic?: boolean; /** Strikes the text through: an old price, a completed task. */ strike?: boolean; /** Horizontal alignment within whatever the text is laid out in. */ align?: 'left' | 'center' | 'right'; /** Case, applied for display without changing the string underneath. */ transform?: 'uppercase' | 'lowercase' | 'capitalize'; } /** Heading levels, for `Typography.Heading`. */ type HeadingType = Extract; /** Body sizes, for `Typography.Paragraph`. */ type ParagraphType = Extract< TypographyType, 'body' | 'body-sm' | 'body-xs' | 'lead' | 'large' | 'small' >; /** Maps a heading preset to its accessibility heading level. */ const HEADING_LEVEL: Record = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6, }; const TypographyRoot = forwardRef( ( { className, type, muted, weight, align, transform, underline, italic, strike, ...props }, ref ) => ( ) ); TypographyRoot.displayName = 'Typography'; export interface TypographyHeadingProps extends Omit { type?: HeadingType; } /** Heading text, wired up with the matching accessibility heading level. */ const TypographyHeading = forwardRef( ( { className, type = 'h2', muted, weight, align, transform, underline, italic, strike, ...props }, ref ) => ( ) ); TypographyHeading.displayName = 'Typography.Heading'; export interface TypographyParagraphProps extends Omit { type?: ParagraphType; } const TypographyParagraph = forwardRef( ( { className, type = 'body', muted, weight, align, transform, underline, italic, strike, ...props }, ref ) => ( ) ); TypographyParagraph.displayName = 'Typography.Paragraph'; export interface TypographyCodeProps extends Omit { /** Classes for the surface behind the code text. */ containerClassName?: string; } /** Inline code on a muted surface. */ const TypographyCode = forwardRef>( ({ className, containerClassName, muted, testID, ...props }, ref) => ( ) ); TypographyCode.displayName = 'Typography.Code'; export interface TypographyBlockquoteProps extends Omit { /** Classes for the row that carries the rule. */ containerClassName?: string; } /** * A quotation, marked by a rule down its leading edge. * * The rule uses `border-s`, so it moves to the right-hand side under a * right-to-left `Direction` without the quote having to know. */ const TypographyBlockquote = forwardRef( ({ className, containerClassName, muted, weight, align, transform, underline, italic = true, strike, children, ...props }, ref) => ( {children} ) ); TypographyBlockquote.displayName = 'Typography.Blockquote'; export interface TypographyListProps extends ViewProps { className?: string; /** Numbered rather than bulleted. The numbers are drawn, not counted by CSS. */ ordered?: boolean; children?: ReactNode; } /** * A bulleted or numbered list. * * React Native has no list markers at all, so each row is a marker and a text * block side by side. The marker is drawn here rather than in the item, because * only the list knows whether it is a bullet or a number — and only the list * knows which number. */ const TypographyList = forwardRef( ({ className, ordered = false, children, ...props }, ref) => ( {Children.map(children, (child, index) => ( {ordered ? ( {index + 1}. ) : ( // A dot rather than "•": the character's size and baseline vary by // platform font, and a view does not. )} {textChildren(child)} ))} ) ); TypographyList.displayName = 'Typography.List'; export interface TypographyListItemProps extends Omit { type?: ParagraphType; } /** One line of a list. The marker beside it belongs to the list. */ const TypographyListItem = forwardRef( ( { className, type = 'body', muted, weight, align, transform, underline, italic, strike, ...props }, ref ) => ( ) ); TypographyListItem.displayName = 'Typography.ListItem'; export const Typography = Object.assign(TypographyRoot, { Heading: TypographyHeading, Paragraph: TypographyParagraph, Code: TypographyCode, Blockquote: TypographyBlockquote, List: TypographyList, ListItem: TypographyListItem, });