diff --git a/packages/react/src/components/F0Text/Text.tsx b/packages/react/src/components/F0Text/Text.tsx new file mode 100644 index 0000000000..e41b2b97ca --- /dev/null +++ b/packages/react/src/components/F0Text/Text.tsx @@ -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((props) => { + const publicProps = privateProps.reduce((acc, key) => { + const { [key]: _, ...rest } = acc + return rest + }, props as TextInternalProps) + + return +}) + +F0Text.displayName = "F0Text" diff --git a/packages/react/src/components/F0Text/TextInternal.tsx b/packages/react/src/components/F0Text/TextInternal.tsx new file mode 100644 index 0000000000..eac7e63bec --- /dev/null +++ b/packages/react/src/components/F0Text/TextInternal.tsx @@ -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, "className">, + React.RefAttributes { + /** + * 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 + + /** + * 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( + ( + { + 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 ( + + {children as string} + + ) + } + + return createElement( + asTag, + { + ...htmlProps, + className: cn(textVariants({ variant, align }), className), + ref: forwardedRef, + }, + children + ) + } +) + +TextInternal.displayName = "TextInternal" diff --git a/packages/react/src/components/F0Text/__stories__/Text.stories.tsx b/packages/react/src/components/F0Text/__stories__/Text.stories.tsx new file mode 100644 index 0000000000..ec82e7cd87 --- /dev/null +++ b/packages/react/src/components/F0Text/__stories__/Text.stories.tsx @@ -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) => ( +
+
+ +
+
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + variant: "body", + children: "This is a text wrapped in the Text component.", + }, +} + +export const Variants: Story = { + args: { + children: "", + }, + render: () => ( +
+ Heading Text + + This is a description text. Lorem ipsum dolor sit amet, consectetur + adipiscing elit. + + + 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. + + This is a small text. +
+ ), +} + +export const TextAlignment: Story = { + parameters: { + chromatic: { disableSnapshot: true }, + }, + args: { + variant: "body", + children: "Text alignment", + }, + render: (args) => ( +
+ + + +
+ ), +} + +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) => ( +
+ +
+ ), + ], +} diff --git a/packages/react/src/components/F0Text/__tests__/Text.test.tsx b/packages/react/src/components/F0Text/__tests__/Text.test.tsx new file mode 100644 index 0000000000..74507ba73a --- /dev/null +++ b/packages/react/src/components/F0Text/__tests__/Text.test.tsx @@ -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(Test Heading) + + expect(screen.getByText("Test Heading")).toBeInTheDocument() + expect(screen.getByText("Test Heading").tagName).toBe("H2") + }) + + it("renders a centered text", () => { + render(Centered) + + expect(screen.getByText("Centered")).toBeInTheDocument() + expect(screen.getByText("Centered")).toHaveClass("text-center") + }) + + it("renders a small text", () => { + render(Small) + + expect(screen.getByText("Small")).toBeInTheDocument() + expect(screen.getByText("Small")).toHaveClass("text-sm") + }) +}) diff --git a/packages/react/src/components/F0Text/index.tsx b/packages/react/src/components/F0Text/index.tsx new file mode 100644 index 0000000000..7ac2d98669 --- /dev/null +++ b/packages/react/src/components/F0Text/index.tsx @@ -0,0 +1 @@ +export * from "./Text" diff --git a/packages/react/src/components/F0Text/types.ts b/packages/react/src/components/F0Text/types.ts new file mode 100644 index 0000000000..aabcc80745 --- /dev/null +++ b/packages/react/src/components/F0Text/types.ts @@ -0,0 +1,18 @@ +import { type VariantProps } from "cva" +import { textVariants } from "./variants" + +export type AsAllowedList = + | "div" + | "p" + | "label" + | "span" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "code" + +export type TextVariants = VariantProps +export type TextVariant = NonNullable diff --git a/packages/react/src/components/F0Text/variants.ts b/packages/react/src/components/F0Text/variants.ts new file mode 100644 index 0000000000..efec6b976a --- /dev/null +++ b/packages/react/src/components/F0Text/variants.ts @@ -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 = { + "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", +} diff --git a/packages/react/src/components/OneEllipsis/OneEllipsis.tsx b/packages/react/src/components/OneEllipsis/OneEllipsis.tsx index de5607da36..05caf9643a 100644 --- a/packages/react/src/components/OneEllipsis/OneEllipsis.tsx +++ b/packages/react/src/components/OneEllipsis/OneEllipsis.tsx @@ -5,7 +5,15 @@ import { TooltipProvider, TooltipTrigger, } from "@/ui/tooltip" -import { forwardRef, useEffect, useMemo, useRef, useState } from "react" +import { + createElement, + forwardRef, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { AsAllowedList } from "../F0Text/types" const checkForEllipsis = (element: HTMLElement | null, lines: number) => { if (!element) return false @@ -24,6 +32,7 @@ type EllipsisWrapperProps = { lines: number noTooltip?: boolean onHasEllipsisChange: (hasEllipsis: boolean) => void + tag?: AsAllowedList } /** @@ -36,7 +45,15 @@ type EllipsisWrapperProps = { */ const EllipsisWrapper = forwardRef( ( - { children, className, lines, onHasEllipsisChange, noTooltip, ...props }, + { + children, + className, + lines, + onHasEllipsisChange, + noTooltip, + tag = "span", + ...props + }, ref ) => { useEffect(() => { @@ -60,20 +77,20 @@ const EllipsisWrapper = forwardRef( } }, [ref, onHasEllipsisChange, lines]) - return ( - 1 ? `not-supports-[(-webkit-line-clamp:${lines})]:whitespace-nowrap display-[-webkit-box] whitespace-normal line-clamp-${lines}` : "block whitespace-nowrap", className - )} - {...props} - > - {children} - + ), + ...props, + }, + children ) } ) @@ -84,17 +101,24 @@ type OneEllipsisProps = { lines?: number children: string noTooltip?: boolean - tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" + tag?: AsAllowedList } -const OneEllipsis = forwardRef( +const OneEllipsis = forwardRef( ( - { className, lines = 1, children, noTooltip = false, ...props }, + { + className, + lines = 1, + children, + noTooltip = false, + tag = "span", + ...props + }, forwardedRef ) => { const [hasEllipsis, setHasEllipsis] = useState(false) - const internalRef = useRef(null) + const internalRef = useRef(null) const ref = forwardedRef || internalRef const Text = useMemo(() => { @@ -103,6 +127,7 @@ const OneEllipsis = forwardRef( ref={ref} className={className} lines={lines} + tag={tag} onHasEllipsisChange={setHasEllipsis} {...props} data-testid="one-ellipsis" @@ -112,14 +137,12 @@ const OneEllipsis = forwardRef( ) // eslint-disable-next-line react-hooks/exhaustive-deps -- We dont want to track props as dependencies - }, [className, lines, children, ref]) + }, [className, lines, children, tag, ref]) return hasEllipsis && !noTooltip ? ( - - {Text} - + {Text} {children} diff --git a/packages/react/src/components/exports.ts b/packages/react/src/components/exports.ts index 240436505d..6bd6735103 100644 --- a/packages/react/src/components/exports.ts +++ b/packages/react/src/components/exports.ts @@ -5,6 +5,7 @@ export * from "./F0Card" export * from "./F0Checkbox" export * from "./F0ChipList" export * from "./F0Icon" +export * from "./F0Text" export * from "./layouts/exports" export * from "./OneFilterPicker/exports" export * from "./tags/exports"