Doc Typography
The borderless, auto-growing text line for paragraphs, three heading levels and quotes, plus the divider.
npx shadcn@latest add @velaui/doc-typographypnpm dlx velaui add @velaui/doc-typographyyarn dlx shadcn@latest add @velaui/doc-typographybunx --bun shadcn@latest add @velaui/doc-typographyveni add doc-typographyInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/doc-typographypnpm dlx velaui add @velaui/doc-typographyyarn dlx shadcn@latest add @velaui/doc-typographybunx --bun shadcn@latest add @velaui/doc-typographyExamples
- Default
- Headings
- Quote
- Inline formatting
Usage
Props
DocTypography
| Prop | Type | Default |
|---|---|---|
highlights | string[] | — |
onChange | (value: string, caret: number) => void | — |
type | DocTypographyType | "text" |
valuerequired | string | — |
Takes these and every prop of DocTypographyProps.
DocDivider
Takes every prop of React.ComponentProps<"hr">.
Dependencies
- npm
- none besides React
- Installs with it
- Textarea
Source
components/ui/doc-typography.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
export type DocTypographyType = "text" | "h1" | "h2" | "h3" | "quote";
export const DOC_TYPOGRAPHY_STYLE: Record<DocTypographyType, string> = {
text: "text-sm leading-[1.6]",
h1: "text-2xl font-semibold leading-[1.3]",
h2: "text-xl font-semibold leading-[1.3]",
h3: "text-lg font-semibold leading-[1.3]",
quote:
"border-l-2 border-border-default pl-3 text-sm leading-[1.6] text-text-muted",
};
const LABEL: Record<DocTypographyType, string> = {
text: "Paragraph",
h1: "Heading 1",
h2: "Heading 2",
h3: "Heading 3",
quote: "Quote",
};
/** A borderless text line that grows with its content; the block text every doc-* component builds on. */
export interface DocTypographyProps
extends Omit<React.ComponentProps<"textarea">, "value" | "onChange"> {
type?: DocTypographyType;
value: string;
/** Called with the new text and where the caret is in it. */
onChange?: (value: string, caret: number) => void;
/** Exact pieces of `value` to tint, as mentions are: the text is unchanged, a coloured chip shows behind it. */
highlights?: string[];
}
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function DocTypography({
type = "text",
value,
onChange,
highlights,
className,
...props
}: DocTypographyProps) {
const pieces = highlights?.length ? value.split(new RegExp(`(${highlights.map(escape).join("|")})`)) : undefined;
const field = (
<Textarea
rows={1}
variant="fill"
size="sm"
aria-label={LABEL[type]}
value={value}
onChange={(e) => onChange?.(e.target.value, e.target.selectionStart)}
className={cn(
pieces && "relative",
"min-h-0 resize-none rounded-none border-0 bg-transparent p-0 text-text-strong hover:bg-transparent focus-visible:border-0 focus-visible:bg-transparent focus-visible:ring-0",
DOC_TYPOGRAPHY_STYLE[type],
className,
)}
{...props}
/>
);
// The chips live in a copy of the text behind the field: same type, same wrapping, invisible text, tinted marks.
return pieces ? (
<div className="relative">
<div
aria-hidden
className={cn("pointer-events-none absolute inset-0 break-words whitespace-pre-wrap text-transparent", DOC_TYPOGRAPHY_STYLE[type], className)}
>
{pieces.map((piece, i) =>
i % 2 ? (
<mark key={i} className="rounded-sm bg-info text-transparent">
{piece}
</mark>
) : (
piece
),
)}
</div>
{field}
</div>
) : (
field
);
}
function DocDivider({ className, ...props }: React.ComponentProps<"hr">) {
return (
<hr
aria-label="Divider"
className={cn("my-3 border-t border-border-default", className)}
{...props}
/>
);
}
export { DocTypography, DocDivider };