diff --git a/packages/frappe-ui-react/src/components/index.ts b/packages/frappe-ui-react/src/components/index.ts index a1a7101c..6efd6cd7 100644 --- a/packages/frappe-ui-react/src/components/index.ts +++ b/packages/frappe-ui-react/src/components/index.ts @@ -30,6 +30,7 @@ export * from "./spinner"; export * from "./switch"; export { default as TabButtons } from "./tabButtons"; export * from "./tabs"; +export * from "./tag"; export { default as TaskStatus } from "./taskStatus"; export * from "./textInput"; export * from "./textarea"; diff --git a/packages/frappe-ui-react/src/components/tag/index.ts b/packages/frappe-ui-react/src/components/tag/index.ts new file mode 100644 index 00000000..59beec73 --- /dev/null +++ b/packages/frappe-ui-react/src/components/tag/index.ts @@ -0,0 +1,2 @@ +export { default as Tag } from "./tag"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/tag/tag.stories.tsx b/packages/frappe-ui-react/src/components/tag/tag.stories.tsx new file mode 100644 index 00000000..b977f647 --- /dev/null +++ b/packages/frappe-ui-react/src/components/tag/tag.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Plus } from "lucide-react"; + +import Tag from "./tag"; +import type { TagProps } from "./types"; + +export default { + title: "Components/Tag", + component: Tag, + tags: ["autodocs"], + argTypes: { + className: { + control: "text", + description: "Additional CSS classes for the tag", + }, + size: { + control: { type: "select", options: ["sm", "md", "lg"] }, + description: "Size of the tag", + }, + variant: { + control: { + type: "select", + options: ["solid", "subtle", "outline", "ghost"], + }, + description: "Variant style of the tag", + }, + label: { + control: "text", + description: "Text label displayed inside the tag", + }, + disabled: { + control: "boolean", + description: "Disables the tag when set to true", + }, + prefixIcon: { + control: false, + description: "Icon component displayed before the label", + }, + suffixIcon: { + control: false, + description: "Icon component displayed after the label", + }, + visible: { + control: "boolean", + description: "Controls the visibility of the tag (controlled mode)", + }, + onVisibleChange: { + action: "visibility changed", + description: "Callback function when the visibility of the tag changes", + }, + onRemove: { + action: "removed", + description: "Callback function when the remove icon is clicked", + }, + }, + parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + size: "sm", + variant: "solid", + label: "Discover", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Subtle: Story = { + args: { + size: "sm", + variant: "subtle", + label: "Discover", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Outline: Story = { + args: { + size: "sm", + variant: "outline", + label: "Discover", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Ghost: Story = { + args: { + size: "sm", + variant: "ghost", + label: "Discover", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Disabled: Story = { + args: { + size: "md", + variant: "solid", + label: "Discover", + disabled: true, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WithPrefix: Story = { + args: { + size: "md", + variant: "solid", + label: "Mobile", + prefixIcon: () => , + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/frappe-ui-react/src/components/tag/tag.tsx b/packages/frappe-ui-react/src/components/tag/tag.tsx new file mode 100644 index 00000000..6d73bc86 --- /dev/null +++ b/packages/frappe-ui-react/src/components/tag/tag.tsx @@ -0,0 +1,78 @@ +/** + * External dependencies. + */ +import { useCallback, useState } from "react"; +import clsx from "clsx"; +import { X } from "lucide-react"; + +/** + * Internal dependencies. + */ +import type { TagProps } from "./types"; +import Button from "../button/button"; + +const Tag = ({ + size, + variant, + label, + prefixIcon, + suffixIcon: SuffixIcon = X, + className, + disabled = false, + visible: controlledVisible, + onVisibleChange, + onRemove, +}: TagProps) => { + const [internalVisible, setInternalVisible] = useState(true); + + const isControlled = controlledVisible !== undefined; + const visible = isControlled ? controlledVisible : internalVisible; + + const handleRemove = useCallback(() => { + if (isControlled) { + onVisibleChange?.(false); + } else { + setInternalVisible(false); + } + onRemove?.(); + }, [isControlled, onVisibleChange, onRemove]); + + if (!visible) return null; + + return ( + + )} + className={clsx( + "focus:border-gray-900 focus:outline-none focus:ring-2 focus:ring-outline-gray-3 gap-1.25! cursor-auto!", + size === "sm" && "text-xs! h-5! rounded-[5px]! px-1.5! py-0.75!", + size === "md" && "text-sm! h-6! rounded-[6px]! px-1.5! py-1!", + size === "lg" && "text-base! h-7! rounded-[8px]! px-2! py-1.5!", + className + )} + disabled={disabled} + /> + ); +}; + +export default Tag; diff --git a/packages/frappe-ui-react/src/components/tag/tests/tag.tsx b/packages/frappe-ui-react/src/components/tag/tests/tag.tsx new file mode 100644 index 00000000..74b7fe13 --- /dev/null +++ b/packages/frappe-ui-react/src/components/tag/tests/tag.tsx @@ -0,0 +1,77 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Tag from "../tag"; + +describe("Tag Component", () => { + it("renders with label", () => { + render(); + expect(screen.getByText("Test Tag")).toBeInTheDocument(); + }); + + it("renders prefix icon", () => { + const PrefixIcon = () => ; + render(); + expect(screen.getByTestId("prefix-icon")).toBeInTheDocument(); + }); + + it("handles uncontrolled removal", () => { + render(); + // Expect the label to be present + expect(screen.getByText("Removable Tag")).toBeInTheDocument(); + + const removeButton = screen.getByLabelText("Remove tag"); + fireEvent.click(removeButton); + + // After click, component should return null + expect(screen.queryByText("Removable Tag")).not.toBeInTheDocument(); + }); + + it("handles controlled removal", () => { + const handleVisibleChange = jest.fn(); + const handleRemove = jest.fn(); + + const { rerender } = render( + + ); + + const removeButton = screen.getByLabelText("Remove tag"); + fireEvent.click(removeButton); + + expect(handleVisibleChange).toHaveBeenCalledWith(false); + expect(handleRemove).toHaveBeenCalled(); + + rerender( + + ); + expect(screen.queryByText("Controlled Tag")).not.toBeInTheDocument(); + }); + + it("respects disabled state", () => { + const handleRemove = jest.fn(); + render(); + + const removeButton = screen.getByLabelText("Remove tag"); + expect(removeButton).toBeDisabled(); + + fireEvent.click(removeButton); + expect(handleRemove).not.toHaveBeenCalled(); + }); + + it("renders with custom class name", () => { + render(); + const tagText = screen.getByText("Custom Class"); + // The button that contains the text + const button = tagText.closest("button"); + expect(button).toHaveClass("my-custom-class"); + }); +}); diff --git a/packages/frappe-ui-react/src/components/tag/types.ts b/packages/frappe-ui-react/src/components/tag/types.ts new file mode 100644 index 00000000..c56356ea --- /dev/null +++ b/packages/frappe-ui-react/src/components/tag/types.ts @@ -0,0 +1,16 @@ +import type { ButtonVariant } from "../button/types"; + +export interface TagProps { + size?: "sm" | "md" | "lg"; + variant?: ButtonVariant; + label?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prefixIcon?: React.ComponentType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + suffixIcon?: React.ComponentType; + className?: string; + disabled?: boolean; + visible?: boolean; + onVisibleChange?: (visible: boolean) => void; + onRemove?: () => void; +}