diff --git a/apps/tester/tests/79-typography.spec.tsx b/apps/tester/tests/79-typography.spec.tsx new file mode 100644 index 0000000..8dd03a3 --- /dev/null +++ b/apps/tester/tests/79-typography.spec.tsx @@ -0,0 +1,436 @@ +import InfoOutlined from '@mui/icons-material/InfoOutlined'; +import { MdInfoOutline } from 'react-icons/md'; +import { Typography as JoyTypography, Chip as JoyChip } from '@mui/joy'; +import { Typography as TJTypography } from 'tailwind-joy/components'; + +import type { Fixture } from '@/settings'; +import { testEach } from '@/settings'; + +const containerClassName = + 'flex h-[400px] w-[400px] items-center justify-center p-2'; + +const fixtures: Fixture[] = [ + { + title: 'levels', + alterSizes: ['md'], + alterVariants: [ + // @ts-expect-error + undefined, + 'solid', + 'soft', + 'outlined', + 'plain', + ], + alterColors: [ + // @ts-expect-error + undefined, + 'primary', + 'neutral', + 'danger', + 'success', + 'warning', + ], + renderJoyElement({ testId, size, variant, color }) { + return ( +
+ + h1 + + + h2 + + + h3 + + + h4 + + + title-lg + + + title-md + + + title-sm + + + body-lg + + + body-md + + + body-sm + + + body-xs + +
+ ); + }, + renderTjElement({ testId, size, variant, color }) { + return ( +
+ + h1 + + + h2 + + + h3 + + + h4 + + + title-lg + + + title-md + + + title-sm + + + body-lg + + + body-md + + + body-sm + + + body-xs + +
+ ); + }, + }, + { + title: 'decorators', + alterSizes: ['md'], + alterVariants: [ + // @ts-expect-error + undefined, + 'solid', + 'soft', + 'outlined', + 'plain', + ], + alterColors: [ + // @ts-expect-error + undefined, + 'primary', + 'neutral', + 'danger', + 'success', + 'warning', + ], + renderJoyElement({ testId, size, variant, color }) { + return ( +
+ } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + +
+ ); + }, + renderTjElement({ testId, size, variant, color, iconClassName }) { + return ( +
+ } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + + } + endDecorator={ + + 123 + + } + > + Lorem ipsum + +
+ ); + }, + }, +]; + +testEach(fixtures, { + containerClassName, + viewport: { width: 500, height: 500 }, +}); diff --git a/apps/website/docs/apis/79-typography.md b/apps/website/docs/apis/79-typography.md new file mode 100644 index 0000000..f6c5a49 --- /dev/null +++ b/apps/website/docs/apis/79-typography.md @@ -0,0 +1,139 @@ +--- +sidebar_position: 79 +title: +--- + +# Typography API + + + +API reference docs for the React Typography component. +Learn about the props of this exported module. + +## Demos + +:::tip + +For examples and details on the usage of this React component, visit the component demo pages: + +- [Typography](../components/typography) + +::: + +## Import + +```tsx +import { Typography } from 'tailwind-joy/components'; +``` + +## Props + +:::info + +The `ref` is forwarded to the root element. + +::: + +### `className` + +Class name applied to the root element. + +- Type: `string` + +### `color` + +The color of the component. + +- Type: `'primary' | 'neutral' | 'danger' | 'success' | 'warning'` +- Default: `variant ? 'neutral' : undefined` + +### `component` + +The component used for the root node. + +- Type: `keyof JSX.IntrinsicElements` +- Default: `'span'` if the component is nested, otherwise it respects `levelMapping[level]`. + +### `endDecorator` + +Element placed after the children. + +- Type: `ReactNode` + +### `gutterBottom` + +If `true`, the text will have a bottom margin. + +- Type: `boolean` +- Default: `false` + +### `level` + +Applies the theme typography styles. + +- Type: `'h1' | 'h2' | 'h3' | 'h4' | 'title-lg' | 'title-md' | 'title-sm' | 'body-lg' | 'body-md' | 'body-sm' | 'body-xs' | 'inherit'` +- Default: `'body-md'` + +### `levelMapping` + +The component maps the variant prop to a range of different HTML element types. +If you wish to change that mapping, you can provide your own. +Alternatively, you can use the `component` prop. + +- Type: `Partial>` +- Default: + ```tsx + { + h1: 'h1', + h2: 'h2', + h3: 'h3', + h4: 'h4', + 'title-lg': 'p', + 'title-md': 'p', + 'title-sm': 'p', + 'body-lg': 'p', + 'body-md': 'p', + 'body-sm': 'p', + 'body-xs': 'span', + inherit: 'p', + } + ``` + +### `noWrap` + +If `true`, the text will not wrap, but instead will truncate with a text overflow ellipsis. + +- Type: `boolean` +- Default: `false` + +### `slotProps` + +The props used for each slot inside. + +- Type: + ```tsx + { + root?: ComponentProps<'a'>; + startDecorator?: ComponentProps<'span'>; + endDecorator?: ComponentProps<'span'>; + } + ``` +- Default: `{}` + +### `startDecorator` + +Element placed before the children. + +- Type: `ReactNode` + +### `textColor` + +Arbitrary text color. + +- Type: `string` + +### `variant` + +The variant of the component. + +- Type: `'solid' | 'soft' | 'outlined' | 'plain'` diff --git a/apps/website/docs/components/02-data-display/09-typography.mdx b/apps/website/docs/components/02-data-display/09-typography.mdx new file mode 100644 index 0000000..f5e139c --- /dev/null +++ b/apps/website/docs/components/02-data-display/09-typography.mdx @@ -0,0 +1,340 @@ +--- +sidebar_position: 9 +slug: /components/typography +--- + +import { + TypographyBasics, + TypographyHeading, + TypographyTitleAndBody, + TypographyNestedTypography, + TypographyLevels, + TypographySemanticElements, + TypographyDecorators, +} from '@site/src/demos/typography'; + +# Typography + + + +The Typography component helps present design and content clearly and efficiently. + +## Basics + +```tsx +import { Typography } from 'tailwind-joy/components'; +``` + +The Typography component wraps around its content, and displays text with specific typographic styles and properties. + + + +```tsx +import { Sheet, Typography } from 'tailwind-joy/components'; + +export function TypographyBasics() { + return ( + + National Parks + + Yosemite National Park + + + Yosemite National Park is a national park spanning 747,956 acres + (1,169.4 sq mi; 3,025.2 km2) in the western Sierra Nevada of Central + California. + + + ); +} +``` + +### Heading + +Use `h1` through `h4` to render a headline. +The produced HTML element will match the semantic [headings](https://www.w3.org/WAI/tutorials/page-structure/headings/) of the page structure. + + + +```tsx +import { Box, Typography } from 'tailwind-joy/components'; + +export function TypographyHeading() { + return ( + + h1: Lorem ipsum + h2: What is Lorem Ipsum? + h3: The standard Lorem Ipsum passage. + h4: The smallest headline of the page + + ); +} +``` + +### Title and body + +Aside from the heading typographic levels, the Typography component also provides the `title-*` and `body-*` type levels. + +To ensure proper information hierarchy, we recommend combining them using either the same size or a lower one. +For example, using `title-lg` with `body-lg` or `title-md` with `body-sm`. + + + +```tsx +import { Box, Sheet, Typography } from 'tailwind-joy/components'; + +export function TypographyTitleAndBody() { + return ( + + + + Title of the component{' '} + + title-lg + + + + This is the description of the component that contain some information + of it.{' '} + + body-md + + + + + + Title of the component{' '} + + title-md + + + + This is the description of the component that contain some information + of it.{' '} + + body-md + + + + Metadata, for example a date.{' '} + + body-sm + + + + + + Title of the component{' '} + + title-sm + + + + This is the description of the component that contain some information + of it.{' '} + + body-sm + + + + Metadata, for example a date.{' '} + + body-xs + + + + + ); +} +``` + +### Nested Typography + +The Typography component renders as a `

` by default. +Nested Typography components are rendered as `` elements (unless customized by [the `component` prop](#semantic-elements)). + + + +```tsx +import { Typography } from 'tailwind-joy/components'; + +export function TypographyNestedTypography() { + return ( + + Typography lets you create nested{' '} + typography. Use your{' '} + + imagination + {' '} + to build wonderful{' '} + + user interface + + . + + ); +} +``` + +## Customization + +### Levels + +The `level` prop gives access to a pre-defined scale of typographic values defined in the theme. +These values include various heading levels (h1, h2, h3, etc.) as well as body text levels (body-md, body-sm, etc) and can be used to apply consistent typography throughout your application. +Additionally, you can also use the level prop to control the font size, weight, line height, and other typographic properties. + + + +```tsx +import { Typography } from 'tailwind-joy/components'; + +export function TypographyLevels() { + return ( +

+ h1 + h2 + h3 + h4 + title-lg + title-md + title-sm + body-lg + body-md + body-sm + body-xs +
+ ); +} +``` + +### Semantic elements + +To customize the semantic element used, you can use the `component` prop. +This can be useful in situations where you want to use a different semantic element than the one assigned by the `level` prop. +The component will render as the HTML element defined by `component`, but with the styles assigned to its respective `level`. + + + +```tsx +import { Typography } from 'tailwind-joy/components'; + +export function TypographySemanticElements() { + return ( + + I render as an h2, but I have h1 styles + + ); +} +``` + +### Decorators + +Use the `startDecorator` and `endDecorator` props to add supporting icons or elements to the Typography. + + + +```tsx +import { MdInfoOutline } from 'react-icons/md'; +import { Typography } from 'tailwind-joy/components'; +import { iconClass } from 'tailwind-joy/utils'; + +export function TypographyDecorators() { + return ( +
+ } + className="mb-4" + > + The icon automatically adjusts to the scale + + + + 123 + + + } + className="justify-center" + > + The display also changes to flexbox + +
+ ); +} +``` + +## Anatomy + +The Typography component is composed of a single root `

` that's assigned the `body-md` class, unless these defaults are overridden by the [`level`](#levels) and/or [`component`](#semantic-elements) props. + +When one Typography component is nested within another, the nested component renders as a `` (unless customized as described above). + +```html +

+ + + + +

+``` + +## API + +See the documentation below for a complete reference to all of the props available to the components mentioned here. + +- [``](../apis/box) +- [``](../apis/sheet) +- [``](../apis/typography) diff --git a/apps/website/src/components/docs/DisplayStand.tsx b/apps/website/src/components/docs/DisplayStand.tsx index e2da2f6..343abb8 100644 --- a/apps/website/src/components/docs/DisplayStand.tsx +++ b/apps/website/src/components/docs/DisplayStand.tsx @@ -8,6 +8,7 @@ export function DisplayStand({ children, className }: ComponentProps<'div'>) { return (
+ {/* TODO: Replace Sheet with Card. */} + + National Parks + + Yosemite National Park + + + Yosemite National Park is a national park spanning 747,956 acres + (1,169.4 sq mi; 3,025.2 km2) in the western Sierra Nevada of Central + California. + + + + ); +} diff --git a/apps/website/src/demos/typography/Decorators.tsx b/apps/website/src/demos/typography/Decorators.tsx new file mode 100644 index 0000000..66a5d57 --- /dev/null +++ b/apps/website/src/demos/typography/Decorators.tsx @@ -0,0 +1,39 @@ +import { MdInfoOutline } from 'react-icons/md'; +import { Typography } from 'tailwind-joy/components'; +import { iconClass } from 'tailwind-joy/utils'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function TypographyDecorators() { + return ( + +
+ } + className="mb-4" + > + The icon automatically adjusts to the scale + + + + 123 + + + } + className="justify-center" + > + The display also changes to flexbox + +
+
+ ); +} diff --git a/apps/website/src/demos/typography/Heading.tsx b/apps/website/src/demos/typography/Heading.tsx new file mode 100644 index 0000000..159f7c0 --- /dev/null +++ b/apps/website/src/demos/typography/Heading.tsx @@ -0,0 +1,20 @@ +import { Box, Typography } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function TypographyHeading() { + return ( + + {/* TODO: Replace Box with Stack. */} + + h1: Lorem ipsum + h2: What is Lorem Ipsum? + + h3: The standard Lorem Ipsum passage. + + + h4: The smallest headline of the page + + + + ); +} diff --git a/apps/website/src/demos/typography/Levels.tsx b/apps/website/src/demos/typography/Levels.tsx new file mode 100644 index 0000000..b2a5665 --- /dev/null +++ b/apps/website/src/demos/typography/Levels.tsx @@ -0,0 +1,22 @@ +import { Typography } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function TypographyLevels() { + return ( + +
+ h1 + h2 + h3 + h4 + title-lg + title-md + title-sm + body-lg + body-md + body-sm + body-xs +
+
+ ); +} diff --git a/apps/website/src/demos/typography/NestedTypography.tsx b/apps/website/src/demos/typography/NestedTypography.tsx new file mode 100644 index 0000000..c6e7d21 --- /dev/null +++ b/apps/website/src/demos/typography/NestedTypography.tsx @@ -0,0 +1,21 @@ +import { Typography } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function TypographyNestedTypography() { + return ( + + + Typography lets you create{' '} + nested typography. Use your{' '} + + imagination + {' '} + to build wonderful{' '} + + user interface + + . + + + ); +} diff --git a/apps/website/src/demos/typography/SemanticElements.tsx b/apps/website/src/demos/typography/SemanticElements.tsx new file mode 100644 index 0000000..c31dc96 --- /dev/null +++ b/apps/website/src/demos/typography/SemanticElements.tsx @@ -0,0 +1,12 @@ +import { Typography } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function TypographySemanticElements() { + return ( + + + I render as an h2, but I have h1 styles + + + ); +} diff --git a/apps/website/src/demos/typography/TitleAndBody.tsx b/apps/website/src/demos/typography/TitleAndBody.tsx new file mode 100644 index 0000000..0773649 --- /dev/null +++ b/apps/website/src/demos/typography/TitleAndBody.tsx @@ -0,0 +1,113 @@ +import { Box, Sheet, Typography } from 'tailwind-joy/components'; +import { DisplayStand } from '@site/src/components/docs/DisplayStand'; + +export function TypographyTitleAndBody() { + return ( + + {/* TODO: Replace Box with Stack. */} + + {/* TODO: Replace Sheet with Card. */} + + + Title of the component{' '} + + title-lg + + + + This is the description of the component that contain some + information of it.{' '} + + body-md + + + + {/* TODO: Replace Sheet with Card. */} + + + Title of the component{' '} + + title-md + + + + This is the description of the component that contain some + information of it.{' '} + + body-md + + + + Metadata, for example a date.{' '} + + body-sm + + + + {/* TODO: Replace Sheet with Card. */} + + + Title of the component{' '} + + title-sm + + + + This is the description of the component that contain some + information of it.{' '} + + body-sm + + + + Metadata, for example a date.{' '} + + body-xs + + + + + + ); +} diff --git a/apps/website/src/demos/typography/index.ts b/apps/website/src/demos/typography/index.ts new file mode 100644 index 0000000..20ea74b --- /dev/null +++ b/apps/website/src/demos/typography/index.ts @@ -0,0 +1,7 @@ +export { TypographyBasics } from './Basics'; +export { TypographyHeading } from './Heading'; +export { TypographyTitleAndBody } from './TitleAndBody'; +export { TypographyNestedTypography } from './NestedTypography'; +export { TypographyLevels } from './Levels'; +export { TypographySemanticElements } from './SemanticElements'; +export { TypographyDecorators } from './Decorators'; diff --git a/packages/tailwind-joy/README.md b/packages/tailwind-joy/README.md index 5906dd5..3cc3a0b 100644 --- a/packages/tailwind-joy/README.md +++ b/packages/tailwind-joy/README.md @@ -22,6 +22,7 @@ https://tailwind-joy.vercel.app ### Data display - Divider +- Typography ### Feedback diff --git a/packages/tailwind-joy/src/base/theme.ts b/packages/tailwind-joy/src/base/theme.ts index 70f419f..13b939a 100644 --- a/packages/tailwind-joy/src/base/theme.ts +++ b/packages/tailwind-joy/src/base/theme.ts @@ -1081,4 +1081,194 @@ export const theme = { }, }, }, + typography: { + h1: { + className: [ + 'font-[var(--joy-fontWeight-xl,700)]', + 'text-[length:var(--joy-fontSize-xl4,2.25rem)]', + '[line-height:var(--joy-lineHeight-xs,1.33334)]', + 'tracking-[-0.025em]', + colorTokens.text.primary, + ], + values: { + fontWeight: 'var(--joy-fontWeight-xl,700)', + fontSize: 'var(--joy-fontSize-xl4,2.25rem)', + lineHeight: 'var(--joy-lineHeight-xs,1.33334)', + letterSpacing: '-0.025em', + }, + tokens: { + color: baseTokens.text.primary, + }, + }, + h2: { + className: [ + 'font-[var(--joy-fontWeight-xl,700)]', + 'text-[length:var(--joy-fontSize-xl3,1.875rem)]', + '[line-height:var(--joy-lineHeight-xs,1.33334)]', + 'tracking-[-0.025em]', + colorTokens.text.primary, + ], + values: { + fontWeight: 'var(--joy-fontWeight-xl,700)', + fontSize: 'var(--joy-fontSize-xl3,1.875rem)', + lineHeight: 'var(--joy-lineHeight-xs,1.33334)', + letterSpacing: '-0.025em', + }, + tokens: { + color: baseTokens.text.primary, + }, + }, + h3: { + className: [ + 'font-[var(--joy-fontWeight-lg,600)]', + 'text-[length:var(--joy-fontSize-xl2,1.5rem)]', + '[line-height:var(--joy-lineHeight-xs,1.33334)]', + 'tracking-[-0.025em]', + colorTokens.text.primary, + ], + values: { + fontWeight: 'var(--joy-fontWeight-lg,600)', + fontSize: 'var(--joy-fontSize-xl2,1.5rem)', + lineHeight: 'var(--joy-lineHeight-xs,1.33334)', + letterSpacing: '-0.025em', + }, + tokens: { + color: baseTokens.text.primary, + }, + }, + h4: { + className: [ + 'font-[var(--joy-fontWeight-lg,600)]', + 'text-[length:var(--joy-fontSize-xl,1.25rem)]', + '[line-height:var(--joy-lineHeight-md,1.5)]', + 'tracking-[-0.025em]', + colorTokens.text.primary, + ], + values: { + fontWeight: 'var(--joy-fontWeight-lg,600)', + fontSize: 'var(--joy-fontSize-xl,1.25rem)', + lineHeight: 'var(--joy-lineHeight-md,1.5)', + letterSpacing: '-0.025em', + }, + tokens: { + color: baseTokens.text.primary, + }, + }, + 'title-lg': { + className: [ + 'font-[var(--joy-fontWeight-lg,600)]', + 'text-[length:var(--joy-fontSize-lg,1.125rem)]', + '[line-height:var(--joy-lineHeight-xs,1.33334)]', + colorTokens.text.primary, + ], + values: { + fontWeight: 'var(--joy-fontWeight-lg,600)', + fontSize: 'var(--joy-fontSize-lg,1.125rem)', + lineHeight: 'var(--joy-lineHeight-xs,1.33334)', + letterSpacing: undefined, + }, + tokens: { + color: baseTokens.text.primary, + }, + }, + 'title-md': { + className: [ + 'font-[var(--joy-fontWeight-md,500)]', + 'text-[length:var(--joy-fontSize-md,1rem)]', + '[line-height:var(--joy-lineHeight-md,1.5)]', + colorTokens.text.primary, + ], + values: { + fontWeight: 'var(--joy-fontWeight-md,500)', + fontSize: 'var(--joy-fontSize-md,1rem)', + lineHeight: 'var(--joy-lineHeight-md,1.5)', + letterSpacing: undefined, + }, + tokens: { + color: baseTokens.text.primary, + }, + }, + 'title-sm': { + className: [ + 'font-[var(--joy-fontWeight-md,500)]', + 'text-[length:var(--joy-fontSize-sm,0.875rem)]', + '[line-height:var(--joy-lineHeight-sm,1.42858)]', + colorTokens.text.primary, + ], + values: { + fontWeight: 'var(--joy-fontWeight-md,500)', + fontSize: 'var(--joy-fontSize-sm,0.875rem)', + lineHeight: 'var(--joy-lineHeight-sm,1.42858)', + letterSpacing: undefined, + }, + tokens: { + color: baseTokens.text.primary, + }, + }, + 'body-lg': { + className: [ + 'text-[length:var(--joy-fontSize-lg,1.125rem)]', + '[line-height:var(--joy-lineHeight-md,1.5)]', + colorTokens.text.secondary, + ], + values: { + fontWeight: undefined, + fontSize: 'var(--joy-fontSize-lg,1.125rem)', + lineHeight: 'var(--joy-lineHeight-md,1.5)', + letterSpacing: undefined, + }, + tokens: { + color: baseTokens.text.secondary, + }, + }, + 'body-md': { + className: [ + 'text-[length:var(--joy-fontSize-md,1rem)]', + '[line-height:var(--joy-lineHeight-md,1.5)]', + colorTokens.text.secondary, + ], + values: { + fontWeight: undefined, + fontSize: 'var(--joy-fontSize-md,1rem)', + lineHeight: 'var(--joy-lineHeight-md,1.5)', + letterSpacing: undefined, + }, + tokens: { + color: baseTokens.text.secondary, + }, + }, + 'body-sm': { + className: [ + 'text-[length:var(--joy-fontSize-sm,0.875rem)]', + '[line-height:var(--joy-lineHeight-md,1.5)]', + colorTokens.text.tertiary, + ], + values: { + fontWeight: undefined, + fontSize: 'var(--joy-fontSize-sm,0.875rem)', + lineHeight: 'var(--joy-lineHeight-md,1.5)', + letterSpacing: undefined, + }, + tokens: { + color: baseTokens.text.tertiary, + }, + }, + 'body-xs': { + className: [ + 'font-[var(--joy-fontWeight-md,500)]', + 'text-[length:var(--joy-fontSize-xs,0.75rem)]', + '[line-height:var(--joy-lineHeight-md,1.5)]', + colorTokens.text.tertiary, + ], + values: { + fontWeight: 'var(--joy-fontWeight-md,500)', + fontSize: 'var(--joy-fontSize-xs,0.75rem)', + lineHeight: 'var(--joy-lineHeight-md,1.5)', + letterSpacing: undefined, + }, + tokens: { + color: baseTokens.text.tertiary, + }, + }, + }, }; diff --git a/packages/tailwind-joy/src/components.ts b/packages/tailwind-joy/src/components.ts index b0a2741..3dea860 100644 --- a/packages/tailwind-joy/src/components.ts +++ b/packages/tailwind-joy/src/components.ts @@ -12,3 +12,4 @@ export { Radio } from './components/Radio'; export { RadioGroup } from './components/RadioGroup'; export { Sheet } from './components/Sheet'; export { Switch } from './components/Switch'; +export { Typography } from './components/Typography'; diff --git a/packages/tailwind-joy/src/components/Typography.tsx b/packages/tailwind-joy/src/components/Typography.tsx new file mode 100644 index 0000000..cf8db10 --- /dev/null +++ b/packages/tailwind-joy/src/components/Typography.tsx @@ -0,0 +1,316 @@ +import { clsx } from 'clsx'; +import type { ComponentProps, ForwardedRef, ReactNode } from 'react'; +import { + createContext, + forwardRef, + createElement, + cloneElement, + useContext, + useMemo, +} from 'react'; +import { twMerge } from 'tailwind-merge'; +import type { + BaseVariants, + GeneratorInput, + GenericComponentPropsWithVariants, +} from '@/base/types'; +import { theme } from '../base/theme'; +import { baseTokens } from '../base/tokens'; +import { excludeClassName } from '../base/utils'; + +type TypographyLevel = + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'title-lg' + | 'title-md' + | 'title-sm' + | 'body-lg' + | 'body-md' + | 'body-sm' + | 'body-xs' + | 'inherit'; + +export const TypographyNestedContext = createContext(false); + +const defaultLevelMapping: Record< + TypographyLevel, + keyof JSX.IntrinsicElements +> = { + h1: 'h1', + h2: 'h2', + h3: 'h3', + h4: 'h4', + 'title-lg': 'p', + 'title-md': 'p', + 'title-sm': 'p', + 'body-lg': 'p', + 'body-md': 'p', + 'body-sm': 'p', + 'body-xs': 'span', + inherit: 'p', +}; + +function typographyStartDecoratorVariants() { + return twMerge( + clsx([ + 'tj-typography-start-decorator', + 'inline-flex', + 'me-[clamp(4px,var(--Typography-gap,0.375em),0.75rem)]', + ]), + ); +} + +function typographyEndDecoratorVariants() { + return twMerge( + clsx([ + 'tj-typography-end-decorator', + 'inline-flex', + 'ms-[clamp(4px,var(--Typography-gap,0.375em),0.75rem)]', + ]), + ); +} + +function typographyRootVariants( + props?: Pick & { + gutterBottom?: boolean; + hasEndDecorator?: boolean; + hasSkeleton?: boolean; + hasStartDecorator?: boolean; + level?: TypographyLevel; + nesting?: boolean; + noWrap?: boolean; + }, +) { + const { + color, + variant, + gutterBottom = false, + hasEndDecorator = false, + hasSkeleton = false, + hasStartDecorator = false, + level = 'body-md', + nesting = false, + noWrap = false, + } = props ?? {}; + const lineHeight = + level !== 'inherit' ? theme.typography[level].values.lineHeight : '1'; + + return twMerge( + clsx([ + 'tj-typography-root group/tj-typography', + `[--Icon-fontSize:calc(1em*${lineHeight})]`, + color && '[--Icon-color:currentColor]', + 'm-[var(--Typography-margin,0px)]', + nesting ? 'inline' : ['block', hasSkeleton && 'relative'], + (hasStartDecorator || hasEndDecorator) && [ + 'flex', + 'items-center', + nesting && ['inline-flex', hasStartDecorator && 'align-bottom'], + ], + level !== 'inherit' && theme.typography[level].className, + level !== 'inherit' + ? `text-[length:var(--Typography-fontSize,${theme.typography[level].values.fontSize})]` + : 'text-[length:var(--Typography-fontSize,inherit)]', + noWrap && 'truncate', + gutterBottom && 'mb-[0.35em]', + color && + baseTokens[color].mainChannel.replace( + /(joy-[a-z0-9]+-[a-z0-9]+)/g, + 'text-[var(--variant-plainColor,var(--$1))]', + ), + variant && [ + 'rounded-[2px]', + '[padding-block:min(0.1em,4px)]', + '[padding-inline:0.25em]', + !nesting && '[margin-inline:-0.25em]', + color && theme.variants[variant][color].className, + ], + ]), + ); +} + +type TypographyRootVariants = Pick & { + endDecorator?: ReactNode; + gutterBottom?: boolean; + level?: TypographyLevel; + levelMapping?: Partial>; + noWrap?: boolean; + startDecorator?: ReactNode; + textColor?: string; +} & { + slotProps?: { + root?: ComponentProps<'a'>; + startDecorator?: ComponentProps<'span'>; + endDecorator?: ComponentProps<'span'>; + }; +}; + +type TypographyRootProps = GenericComponentPropsWithVariants< + 'span', + TypographyRootVariants, + T +>; + +function TypographyRoot< + T extends keyof JSX.IntrinsicElements | undefined = undefined, +>( + { + // ---- non-passing props ---- + // base variants + color, + variant, + + // non-base variants + className, + endDecorator, + gutterBottom = false, + level, + levelMapping = defaultLevelMapping, + noWrap = false, + startDecorator, + style, + textColor, + + // slot props + slotProps = {}, + + // others + component, + children, + ...otherProps + // --------------------------- + }: TypographyRootProps, + ref: ForwardedRef, +) { + const nesting = useContext(TypographyNestedContext); + const slotPropsWithoutClassName = useMemo( + () => excludeClassName(slotProps), + [slotProps], + ); + + const instanceColor = variant ? (color ?? 'neutral') : color; + + const instanceLevel = nesting ? level || 'inherit' : level || 'body-md'; + + // TODO: Implement this while implementing the skeleton component. + const hasSkeleton = false; + + const instanceComponent = + component || + (nesting + ? 'span' + : levelMapping[instanceLevel] || + defaultLevelMapping[instanceLevel] || + 'span'); + + return ( + + {createElement( + instanceComponent, + { + ref, + className: twMerge( + typographyRootVariants({ + color: instanceColor, + variant, + gutterBottom, + hasEndDecorator: Boolean(endDecorator), + hasSkeleton, + hasStartDecorator: Boolean(startDecorator), + level: instanceLevel, + nesting, + noWrap, + }), + className, + slotProps.root?.className ?? '', + ), + style: { + ...style, + ...(textColor === undefined + ? {} + : { + color: textColor, + }), + }, + ...otherProps, + ...(slotPropsWithoutClassName.root ?? {}), + }, + <> + {startDecorator && ( + + {startDecorator} + + )} + {hasSkeleton + ? cloneElement(children as JSX.Element, { + variant: (children as JSX.Element).props.variant || 'inline', + }) + : children} + {endDecorator && ( + + {endDecorator} + + )} + , + )} + + ); +} + +export const Typography = forwardRef(TypographyRoot) as < + T extends keyof JSX.IntrinsicElements | undefined = undefined, +>( + props: TypographyRootProps & { ref?: ForwardedRef }, +) => JSX.Element; + +export const generatorInputs: GeneratorInput[] = [ + { + generatorFn: typographyStartDecoratorVariants, + variants: {}, + }, + { + generatorFn: typographyEndDecoratorVariants, + variants: {}, + }, + { + generatorFn: typographyRootVariants, + variants: { + color: [undefined, 'primary', 'neutral', 'danger', 'success', 'warning'], + variant: [undefined, 'solid', 'soft', 'outlined', 'plain'], + gutterBottom: [false, true], + hasEndDecorator: [false, true], + hasSkeleton: [false, true], + hasStartDecorator: [false, true], + level: [ + 'h1', + 'h2', + 'h3', + 'h4', + 'title-lg', + 'title-md', + 'title-sm', + 'body-lg', + 'body-md', + 'body-sm', + 'body-xs', + 'inherit', + ], + nesting: [false, true], + noWrap: [false, true], + }, + }, +]; diff --git a/packages/tailwind-joy/src/plugins/safelist-generator.ts b/packages/tailwind-joy/src/plugins/safelist-generator.ts index 129a4e2..938e420 100644 --- a/packages/tailwind-joy/src/plugins/safelist-generator.ts +++ b/packages/tailwind-joy/src/plugins/safelist-generator.ts @@ -13,6 +13,7 @@ import { generatorInputs as radioClassNameGeneratorInputs } from '../components/ import { generatorInputs as radioGroupClassNameGeneratorInputs } from '../components/RadioGroup'; import { generatorInputs as sheetClassNameGeneratorInputs } from '../components/Sheet'; import { generatorInputs as switchClassNameGeneratorInputs } from '../components/Switch'; +import { generatorInputs as typographyClassNameGeneratorInputs } from '../components/Typography'; import { generatorInputs as adaptedIconClassNameGeneratorInputs } from '../components/internal/class-adapter'; const SPACE = ' '; @@ -32,6 +33,7 @@ const inputs: GeneratorInput[] = [ ...radioGroupClassNameGeneratorInputs, ...sheetClassNameGeneratorInputs, ...switchClassNameGeneratorInputs, + ...typographyClassNameGeneratorInputs, ...adaptedIconClassNameGeneratorInputs, ];