Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/react/src/components/F0Text/Text.tsx
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"
109 changes: 109 additions & 0 deletions packages/react/src/components/F0Text/TextInternal.tsx
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>
),
],
}
27 changes: 27 additions & 0 deletions packages/react/src/components/F0Text/__tests__/Text.test.tsx
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")
})
})
1 change: 1 addition & 0 deletions packages/react/src/components/F0Text/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Text"
18 changes: 18 additions & 0 deletions packages/react/src/components/F0Text/types.ts
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"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we want to have a text as a div? 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 👍

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

div is a way to render the text as block element.
Maybe we can remove span and div and use the prop block or inline to render them block or inline

| "p"
| "label"
| "span"
| "h1"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondering If makes sense to create an specific component F0Heading for the headings, its a way to control what is a heading or not, visually can be similar to text, but semantic are diferent

| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "code"

export type TextVariants = VariantProps<typeof textVariants>
export type TextVariant = NonNullable<TextVariants["variant"]>
71 changes: 71 additions & 0 deletions packages/react/src/components/F0Text/variants.ts
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",
}
Loading
Loading