-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add Text component #2408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: add Text component #2408
Changes from all commits
b65fa39
1fe36e7
d1db5c0
a9955cb
4b44c11
cf5ce1c
82727f7
c941d26
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { forwardRef } from "react" | ||
import { TextInternal, TextInternalProps } from "./TextInternal" | ||
|
||
const privateProps = ["className"] as const | ||
const _privateVariants = [ | ||
"selected", | ||
"heading-large", | ||
"label-input", | ||
"warning", | ||
"critical", | ||
"positive", | ||
"info", | ||
"warning-strong", | ||
"critical-strong", | ||
"positive-strong", | ||
"info-strong", | ||
] as const | ||
|
||
export type F0TextProps = Omit< | ||
TextInternalProps, | ||
(typeof privateProps)[number] | ||
> & { | ||
variant?: Exclude< | ||
TextInternalProps["variant"], | ||
(typeof _privateVariants)[number] | ||
> | ||
} | ||
|
||
export const F0Text = forwardRef<HTMLElement, F0TextProps>((props) => { | ||
const publicProps = privateProps.reduce((acc, key) => { | ||
const { [key]: _, ...rest } = acc | ||
return rest | ||
}, props as TextInternalProps) | ||
|
||
return <TextInternal {...publicProps} /> | ||
}) | ||
|
||
F0Text.displayName = "F0Text" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { cn } from "@/lib/utils" | ||
import type React from "react" | ||
import { createElement, forwardRef, type ReactNode } from "react" | ||
import { OneEllipsis } from "../OneEllipsis" | ||
import { | ||
type AsAllowedList, | ||
type TextVariant, | ||
type TextVariants, | ||
} from "./types" | ||
import { defaultTag, textVariants } from "./variants" | ||
|
||
export interface TextInternalProps | ||
extends Omit<React.HTMLAttributes<HTMLElement>, "className">, | ||
React.RefAttributes<HTMLElement> { | ||
/** | ||
* Content to be rendered | ||
*/ | ||
children?: ReactNode | ||
|
||
/** | ||
* The text variant to render. Determines styling and default semantic element. | ||
*/ | ||
variant?: TextVariant | ||
|
||
/** | ||
* Text alignment | ||
* @default left | ||
*/ | ||
align?: NonNullable<TextVariants["align"]> | ||
|
||
/** | ||
* Additional classes to apply | ||
* @private | ||
*/ | ||
className?: string | ||
|
||
/** | ||
* HTML tag name to use for rendered element. | ||
* If not provided, uses semantic default based on variant. | ||
* @default varies by variant | ||
*/ | ||
as?: AsAllowedList | ||
|
||
/** | ||
* Enable text ellipsis with optional line configuration | ||
* - `true`: Single line ellipsis (lines = 1) | ||
* - `number`: Multi-line ellipsis with specified line count | ||
* - `undefined`: No ellipsis | ||
*/ | ||
ellipsis?: boolean | number | ||
|
||
/** | ||
* Disable tooltip when text is truncated | ||
* Only applies when ellipsis is enabled | ||
* @default false | ||
*/ | ||
noEllipsisTooltip?: boolean | ||
} | ||
|
||
/** | ||
* Text component for consistent typography across the application. | ||
*/ | ||
export const TextInternal = forwardRef<HTMLElement, TextInternalProps>( | ||
( | ||
{ | ||
children, | ||
variant, | ||
align, | ||
className, | ||
as, | ||
ellipsis, | ||
noEllipsisTooltip, | ||
...htmlProps | ||
}, | ||
forwardedRef | ||
) => { | ||
const asTag = as ?? defaultTag[variant ?? "body"] | ||
|
||
// If ellipsis is enabled, wrap with the ellipsis component | ||
if (ellipsis !== undefined) { | ||
const lines = typeof ellipsis === "number" ? ellipsis : 1 | ||
|
||
return ( | ||
<OneEllipsis | ||
ref={forwardedRef} | ||
lines={lines} | ||
noTooltip={noEllipsisTooltip} | ||
tag={asTag} | ||
className={cn(textVariants({ variant, align }), className)} | ||
{...htmlProps} | ||
> | ||
{children as string} | ||
</OneEllipsis> | ||
) | ||
} | ||
|
||
return createElement( | ||
asTag, | ||
{ | ||
...htmlProps, | ||
className: cn(textVariants({ variant, align }), className), | ||
ref: forwardedRef, | ||
}, | ||
children | ||
) | ||
} | ||
) | ||
|
||
TextInternal.displayName = "TextInternal" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
import type { Meta, StoryObj } from "@storybook/react-vite" | ||
import { F0Text } from "../index" | ||
|
||
const meta = { | ||
component: F0Text, | ||
title: "Text", | ||
tags: ["autodocs", "experimental"], | ||
decorators: [ | ||
(Story) => ( | ||
<div className="flex w-full items-center justify-center p-4"> | ||
<div className="w-full max-w-96"> | ||
<Story /> | ||
</div> | ||
</div> | ||
), | ||
], | ||
} satisfies Meta<typeof F0Text> | ||
|
||
export default meta | ||
type Story = StoryObj<typeof meta> | ||
|
||
export const Default: Story = { | ||
args: { | ||
variant: "body", | ||
children: "This is a text wrapped in the Text component.", | ||
}, | ||
} | ||
|
||
export const Variants: Story = { | ||
args: { | ||
children: "", | ||
}, | ||
render: () => ( | ||
<div className="flex flex-col gap-2"> | ||
<F0Text variant="heading">Heading Text</F0Text> | ||
<F0Text variant="description"> | ||
This is a description text. Lorem ipsum dolor sit amet, consectetur | ||
adipiscing elit. | ||
</F0Text> | ||
<F0Text> | ||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod | ||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim | ||
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea | ||
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate | ||
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint | ||
occaecat cupidatat non proident, sunt in culpa qui officia deserunt | ||
mollit anim id est laborum. | ||
</F0Text> | ||
<F0Text variant="small">This is a small text.</F0Text> | ||
</div> | ||
), | ||
} | ||
|
||
export const TextAlignment: Story = { | ||
parameters: { | ||
chromatic: { disableSnapshot: true }, | ||
}, | ||
args: { | ||
variant: "body", | ||
children: "Text alignment", | ||
}, | ||
render: (args) => ( | ||
<div className="flex flex-col gap-2"> | ||
<F0Text {...args} align="left" /> | ||
<F0Text {...args} align="center" /> | ||
<F0Text {...args} align="right" /> | ||
</div> | ||
), | ||
} | ||
|
||
export const TextEllipsis: Story = { | ||
parameters: { | ||
chromatic: { disableSnapshot: true }, | ||
}, | ||
args: { | ||
variant: "body", | ||
children: | ||
"This is a very long text that will be truncated with ellipsis when it exceeds the container width and demonstrates the ellipsis functionality.", | ||
ellipsis: true, | ||
}, | ||
decorators: [ | ||
(Story) => ( | ||
<div className="max-w-96"> | ||
<Story /> | ||
</div> | ||
), | ||
], | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import { zeroRender as render, screen } from "@/testing/test-utils" | ||
import "@testing-library/jest-dom/vitest" | ||
import { describe, expect, it } from "vitest" | ||
import { F0Text } from "../Text" | ||
|
||
describe("F0Text Component", () => { | ||
it("renders a title", () => { | ||
render(<F0Text variant="heading">Test Heading</F0Text>) | ||
|
||
expect(screen.getByText("Test Heading")).toBeInTheDocument() | ||
expect(screen.getByText("Test Heading").tagName).toBe("H2") | ||
}) | ||
|
||
it("renders a centered text", () => { | ||
render(<F0Text align="center">Centered</F0Text>) | ||
|
||
expect(screen.getByText("Centered")).toBeInTheDocument() | ||
expect(screen.getByText("Centered")).toHaveClass("text-center") | ||
}) | ||
|
||
it("renders a small text", () => { | ||
render(<F0Text variant="small">Small</F0Text>) | ||
|
||
expect(screen.getByText("Small")).toBeInTheDocument() | ||
expect(screen.getByText("Small")).toHaveClass("text-sm") | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./Text" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import { type VariantProps } from "cva" | ||
import { textVariants } from "./variants" | ||
|
||
export type AsAllowedList = | ||
| "div" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why would we want to have a text as a div? 🤔 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've seen it used a lot for generic layouts, but it's probably bad for accessibility, given that we have plenty of other tags that suit text better. I can remove it if we feel it doesn't fit there 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| "p" | ||
| "label" | ||
| "span" | ||
| "h1" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wondering If makes sense to create an specific component |
||
| "h2" | ||
dani-moreno marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "h3" | ||
| "h4" | ||
| "h5" | ||
| "h6" | ||
| "code" | ||
|
||
export type TextVariants = VariantProps<typeof textVariants> | ||
export type TextVariant = NonNullable<TextVariants["variant"]> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { cva } from "cva" | ||
import { type AsAllowedList, type TextVariant } from "./types" | ||
|
||
export const textVariants = cva({ | ||
base: "text-base text-f1-foreground", | ||
variants: { | ||
variant: { | ||
// -- PUBLIC VARIANTS | ||
// Heading | ||
heading: "text-lg font-semibold", | ||
|
||
// Body | ||
body: "", | ||
description: "text-f1-foreground-secondary", | ||
small: "text-sm font-medium text-f1-foreground-secondary", | ||
inverse: "text-f1-foreground-inverse", | ||
code: "text-f1-foreground-secondary", | ||
|
||
// Label | ||
label: "font-medium", | ||
|
||
// -- PRIVATE VARIANTS | ||
// Heading | ||
"heading-large": "text-2xl font-semibold", | ||
|
||
// Label | ||
"label-input": "font-medium text-f1-foreground-secondary", | ||
|
||
// Semantic text | ||
selected: "font-medium text-f1-foreground-selected", | ||
warning: "text-f1-foreground-warning", | ||
critical: "text-f1-foreground-critical", | ||
positive: "text-f1-foreground-positive", | ||
info: "text-f1-foreground-info", | ||
"warning-strong": "font-semibold text-f1-foreground-warning", | ||
"critical-strong": "font-semibold text-f1-foreground-critical", | ||
"positive-strong": "font-semibold text-f1-foreground-positive", | ||
"info-strong": "font-semibold text-f1-foreground-info", | ||
}, | ||
align: { | ||
left: "text-left", | ||
center: "text-center", | ||
right: "text-right", | ||
}, | ||
}, | ||
defaultVariants: { | ||
variant: "body", | ||
align: "left", | ||
}, | ||
}) | ||
|
||
export const defaultTag: Record<TextVariant, AsAllowedList> = { | ||
"heading-large": "h1", | ||
heading: "h2", | ||
body: "p", | ||
description: "p", | ||
label: "p", | ||
"label-input": "label", | ||
small: "p", | ||
selected: "p", | ||
inverse: "p", | ||
warning: "p", | ||
critical: "p", | ||
positive: "p", | ||
info: "p", | ||
"warning-strong": "p", | ||
"critical-strong": "p", | ||
"positive-strong": "p", | ||
"info-strong": "p", | ||
code: "code", | ||
} |
Uh oh!
There was an error while loading. Please reload this page.