/**
* 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,
});