diff --git a/packages/frappe-ui-react/src/components/buttonGroup/buttonGroup.stories.tsx b/packages/frappe-ui-react/src/components/buttonGroup/buttonGroup.stories.tsx new file mode 100644 index 00000000..fc74b080 --- /dev/null +++ b/packages/frappe-ui-react/src/components/buttonGroup/buttonGroup.stories.tsx @@ -0,0 +1,204 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ArrowUpDown, ChevronDown, ListFilter, SmilePlus } from "lucide-react"; + +import ButtonGroup from "./buttonGroup"; +import type { ButtonGroupProps } from "./types"; + +export default { + title: "Components/ButtonGroup", + component: ButtonGroup, + tags: ["autodocs"], + argTypes: { + className: { + control: "text", + description: "Additional CSS classes for the button group container", + }, + buttons: { + control: "object", + description: "Array of button configurations", + }, + size: { + control: { type: "select", options: ["sm", "md", "lg", "xl", "2xl"] }, + description: "Size of the buttons in the group", + }, + variant: { + control: { + type: "select", + options: ["solid", "subtle", "outline", "ghost"], + }, + description: "Variant style of the buttons", + }, + theme: { + control: { type: "select", options: ["gray", "blue", "green", "red"] }, + description: "Theme color of the buttons", + }, + }, + parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + buttons: [ + { + id: "btn-1", + label: "Group by", + }, + { + id: "btn-2", + label: "Sort", + }, + { + id: "btn-3", + label: "Filters", + }, + ], + size: "sm", + variant: "subtle", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const IconSubtle: Story = { + args: { + buttons: [ + { + id: "btn-1", + icon: "phone", + }, + { + id: "btn-2", + icon: "mail", + }, + { + id: "btn-3", + icon: "external-link", + }, + ], + size: "sm", + variant: "subtle", + theme: "gray", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const IconMixedVariant: Story = { + args: { + buttons: [ + { + id: "btn-1", + icon: "corner-up-left", + }, + { + id: "btn-2", + icon: "map-pin", + }, + { + id: "btn-3", + variant: "subtle", + icon: () => , + }, + { + id: "btn-4", + icon: "more-horizontal", + }, + ], + size: "sm", + variant: "ghost", + theme: "gray", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const IconWithLabelSubtle: Story = { + args: { + buttons: [ + { + id: "btn-1", + label: "Save view", + iconLeft: "plus", + }, + { + id: "btn-2", + label: "Sort", + iconLeft: () => , + }, + { + id: "btn-3", + label: "Filter", + iconLeft: () => , + iconRight: () => , + }, + { + id: "btn-4", + label: "Column", + iconLeft: "columns", + }, + { + id: "btn-5", + icon: "more-horizontal", + }, + ], + size: "md", + variant: "subtle", + theme: "gray", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const IconWithLabelOutline: Story = { + args: { + buttons: [ + { + id: "btn-1", + label: "Save view", + iconLeft: "plus", + }, + { + id: "btn-2", + label: "Sort", + iconLeft: () => , + }, + { + id: "btn-3", + label: "Filter", + iconLeft: () => , + iconRight: () => , + }, + { + id: "btn-4", + label: "Column", + iconLeft: "columns", + }, + { + id: "btn-5", + icon: "more-horizontal", + }, + ], + size: "md", + variant: "outline", + theme: "gray", + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/frappe-ui-react/src/components/buttonGroup/buttonGroup.tsx b/packages/frappe-ui-react/src/components/buttonGroup/buttonGroup.tsx new file mode 100644 index 00000000..ce7a8367 --- /dev/null +++ b/packages/frappe-ui-react/src/components/buttonGroup/buttonGroup.tsx @@ -0,0 +1,34 @@ +/** + * External dependencies. + */ +import clsx from "clsx"; + +/** + * Internal dependencies. + */ +import { ButtonGroupProps } from "./types"; +import Button from "../button/button"; + +const ButtonGroup = ({ + buttons, + className, + size, + variant, + theme, +}: ButtonGroupProps) => { + return ( +
+ {buttons.map((buttonProps, index) => ( +
+ ); +}; + +export default ButtonGroup; diff --git a/packages/frappe-ui-react/src/components/buttonGroup/index.ts b/packages/frappe-ui-react/src/components/buttonGroup/index.ts new file mode 100644 index 00000000..223e4db1 --- /dev/null +++ b/packages/frappe-ui-react/src/components/buttonGroup/index.ts @@ -0,0 +1,2 @@ +export { default as ButtonGroup } from "./buttonGroup"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/buttonGroup/tests/buttonGroup.tsx b/packages/frappe-ui-react/src/components/buttonGroup/tests/buttonGroup.tsx new file mode 100644 index 00000000..f2a0e7fc --- /dev/null +++ b/packages/frappe-ui-react/src/components/buttonGroup/tests/buttonGroup.tsx @@ -0,0 +1,64 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import ButtonGroup from "../buttonGroup"; + +describe("ButtonGroup Component", () => { + const defaultButtons = [ + { label: "Button 1", onClick: jest.fn() }, + { label: "Button 2", onClick: jest.fn() }, + ]; + + const renderButtonGroup = (props = {}) => { + return render(); + }; + + it("renders multiple buttons", () => { + renderButtonGroup(); + expect(screen.getByText("Button 1")).toBeInTheDocument(); + expect(screen.getByText("Button 2")).toBeInTheDocument(); + }); + + it("applies shared props to all buttons", () => { + renderButtonGroup({ size: "sm", variant: "outline", theme: "red" }); + + const button1 = screen.getByText("Button 1").closest("button"); + const button2 = screen.getByText("Button 2").closest("button"); + + expect(button1).toHaveClass("h-7"); + expect(button1).toHaveClass("text-red-700"); + expect(button1).toHaveClass("border-outline-red-1"); + + expect(button2).toHaveClass("h-7"); + expect(button2).toHaveClass("text-red-700"); + expect(button2).toHaveClass("border-outline-red-1"); + }); + + it("applies custom global className", () => { + const { container } = renderButtonGroup({ + className: "custom-group-class", + }); + // ButtonGroup wraps buttons in a div + expect(container.firstChild).toHaveClass("custom-group-class"); + expect(container.firstChild).toHaveClass("flex"); + expect(container.firstChild).toHaveClass("gap-1"); + }); + + it("handles click events on individual buttons", () => { + const handleClick1 = jest.fn(); + const handleClick2 = jest.fn(); + + const buttons = [ + { label: "Action 1", onClick: handleClick1 }, + { label: "Action 2", onClick: handleClick2 }, + ]; + + render(); + + fireEvent.click(screen.getByText("Action 1")); + expect(handleClick1).toHaveBeenCalledTimes(1); + expect(handleClick2).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText("Action 2")); + expect(handleClick2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/frappe-ui-react/src/components/buttonGroup/types.ts b/packages/frappe-ui-react/src/components/buttonGroup/types.ts new file mode 100644 index 00000000..27b57cde --- /dev/null +++ b/packages/frappe-ui-react/src/components/buttonGroup/types.ts @@ -0,0 +1,9 @@ +import { ButtonProps, ButtonSize, ButtonTheme, ButtonVariant } from "../button/types"; + +export interface ButtonGroupProps { + buttons: (ButtonProps & { id?: string })[]; + className?: string; + size?: ButtonSize; + variant?: ButtonVariant; + theme?: ButtonTheme; +} diff --git a/packages/frappe-ui-react/src/components/divider/divider.stories.tsx b/packages/frappe-ui-react/src/components/divider/divider.stories.tsx index 7b6f4c0e..aaa026e5 100644 --- a/packages/frappe-ui-react/src/components/divider/divider.stories.tsx +++ b/packages/frappe-ui-react/src/components/divider/divider.stories.tsx @@ -1,5 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ChevronDown } from "lucide-react"; + import Divider from "./divider"; +import { Button } from "../button"; +import { Badge } from "../badge"; const meta: Meta = { title: "Components/Divider", @@ -12,18 +16,26 @@ const meta: Meta = { options: ["horizontal", "vertical"], description: "Orientation of the divider", }, + slot: { + control: false, + description: "Element to render in the divider slot", + }, position: { control: { type: "select" }, options: ["start", "center", "end"], - description: "Position of the action button", + description: "Position of the slot element", + }, + padding: { + control: "number", + description: "Padding around the divider in pixels", }, flexItem: { control: "boolean", description: "If true, adapts to flex container", }, - action: { - control: false, - description: "Optional action button configuration", + className: { + control: "text", + description: "Additional CSS classes to apply to the divider", }, }, }; @@ -33,20 +45,21 @@ type Story = StoryObj; export const Horizontal: Story = { render: (args) => ( -
-

Content above

+
+

Content above

-

Content below

+

Content below

), args: { orientation: "horizontal", + padding: 16, }, }; export const Vertical: Story = { render: (args) => ( -
+
Left Right @@ -55,50 +68,109 @@ export const Vertical: Story = { args: { orientation: "vertical", flexItem: true, + padding: 16, }, }; -export const WithAction: Story = { +export const WithSlot: Story = { render: (args) => ( -
-

Content above

+
+

Content above

-

Content below

+

Content below

), args: { orientation: "horizontal", position: "center", - action: { - label: "Load More", - handler: () => alert("Action clicked!"), - loading: false, - }, + padding: 6, + slot: () =>
+ ), +}; + +export const SlotPositionsVertical: Story = { + render: () => ( +
+
+ ), +}; + +export const Timeline: Story = { render: () => ( -
-
+
+ } + /> + ( + + )} + /> + ( +
-
{} }} - /> -
-
- {} }} + slot={() =>
diff --git a/packages/frappe-ui-react/src/components/divider/divider.tsx b/packages/frappe-ui-react/src/components/divider/divider.tsx index e1c07b40..f803df2b 100644 --- a/packages/frappe-ui-react/src/components/divider/divider.tsx +++ b/packages/frappe-ui-react/src/components/divider/divider.tsx @@ -1,65 +1,72 @@ -import { useMemo } from "react"; +/** + * External dependencies. + */ import clsx from "clsx"; -import { Button } from "../button"; +/** + * Internal dependencies. + */ import { DividerProps } from "./types"; const Divider = ({ orientation = "horizontal", + slot, position = "center", + padding = 0, flexItem = false, - action, + className = "", }: DividerProps) => { - const alignmentClasses = useMemo(() => { - const spacerDimensionClasses = { - horizontal: "border-t w-full", - vertical: "border-l", - }[orientation]; + const isHorizontal = orientation === "horizontal"; + const flexClasses = flexItem ? "self-stretch h-auto" : ""; - const flexClasses = flexItem ? "self-stretch h-auto" : "h-full"; + if (!slot) { + return ( +
+ ); + } - return [spacerDimensionClasses, flexClasses]; - }, [orientation, flexItem]); - - const actionAlignmentClasses = useMemo(() => { - return { - horizontal: { - center: "left-1/2 top-0 -translate-y-1/2 -translate-x-1/2", - start: "left-0 top-0 -translate-y-1/2 ml-4", - end: "right-0 top-0 -translate-y-1/2 mr-4", - }, - vertical: { - center: "-translate-x-1/2 top-1/2 left-0 -translate-y-1/2", - start: "-translate-x-1/2 top-0 mt-4 left-0", - end: "-translate-x-1/2 bottom-0 mb-4 left-0", - }, - }[orientation][position]; - }, [orientation, position]); - - return action ? ( + return (
- -
- ) : ( -
); }; diff --git a/packages/frappe-ui-react/src/components/divider/tests/divider.tsx b/packages/frappe-ui-react/src/components/divider/tests/divider.tsx new file mode 100644 index 00000000..6b39ee59 --- /dev/null +++ b/packages/frappe-ui-react/src/components/divider/tests/divider.tsx @@ -0,0 +1,111 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Divider from "../divider"; + +describe("Divider Component", () => { + describe("Basic rendering", () => { + it("renders horizontal divider by default", () => { + const { container } = render(); + const hr = container.querySelector("hr"); + + expect(hr).toBeInTheDocument(); + expect(hr).toHaveClass("border-t", "w-full"); + expect(hr).not.toHaveClass("border-l", "h-full"); + }); + + it("renders vertical divider", () => { + const { container } = render(); + const hr = container.querySelector("hr"); + + expect(hr).toHaveClass("border-l", "h-full"); + expect(hr).not.toHaveClass("border-t", "w-full"); + }); + }); + + describe("Padding", () => { + it("applies vertical padding for horizontal divider", () => { + const { container } = render(); + const hr = container.querySelector("hr"); + + expect(hr).toHaveStyle({ marginTop: "10px", marginBottom: "10px" }); + }); + + it("applies horizontal padding for vertical divider", () => { + const { container } = render( + + ); + const hr = container.querySelector("hr"); + + expect(hr).toHaveStyle({ marginLeft: "15px", marginRight: "15px" }); + }); + }); + + describe("Divider with slot", () => { + it("renders slot content between dividers", () => { + const { container } = render( Label} />); + + expect(screen.getByText("Label")).toBeInTheDocument(); + + const hrs = container.querySelectorAll("hr"); + expect(hrs).toHaveLength(2); + }); + + it("positions slot at start", () => { + const { container } = render( + "Start"} position="start" /> + ); + const hrs = container.querySelectorAll("hr"); + + expect(hrs[0]).toHaveClass("w-4"); + expect(hrs[1]).toHaveClass("flex-1"); + }); + + it("positions slot at center by default", () => { + const { container } = render( "Center"} />); + const hrs = container.querySelectorAll("hr"); + + expect(hrs[0]).toHaveClass("flex-1"); + expect(hrs[1]).toHaveClass("flex-1"); + }); + + it("positions slot at end", () => { + const { container } = render( + "End"} position="end" /> + ); + const hrs = container.querySelectorAll("hr"); + + expect(hrs[0]).toHaveClass("flex-1"); + expect(hrs[1]).toHaveClass("w-4"); + }); + + it("applies padding to wrapper with slot", () => { + const { container } = render( + "Text"} padding={20} /> + ); + const wrapper = container.querySelector("div"); + + expect(wrapper).toHaveStyle({ + paddingTop: "20px", + paddingBottom: "20px", + }); + }); + }); + + describe("Flex item", () => { + it("applies flex item classes when enabled", () => { + const { container } = render(); + const hr = container.querySelector("hr"); + + expect(hr).toHaveClass("self-stretch", "h-auto"); + }); + }); + + describe("Custom className", () => { + it("applies custom className", () => { + const { container } = render(); + const hr = container.querySelector("hr"); + + expect(hr).toHaveClass("custom-divider"); + }); + }); +}); diff --git a/packages/frappe-ui-react/src/components/divider/types.ts b/packages/frappe-ui-react/src/components/divider/types.ts index 9b5ced05..5edd0eb2 100644 --- a/packages/frappe-ui-react/src/components/divider/types.ts +++ b/packages/frappe-ui-react/src/components/divider/types.ts @@ -1,13 +1,11 @@ -interface DividerAction { - label: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handler: () => any; - loading?: boolean; -} +import { ReactNode } from "react"; export interface DividerProps { orientation?: "horizontal" | "vertical"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + slot?: (args?: any) => ReactNode; position?: "start" | "center" | "end"; + padding?: number; flexItem?: boolean; - action?: DividerAction; + className?: string; } diff --git a/packages/frappe-ui-react/src/components/index.ts b/packages/frappe-ui-react/src/components/index.ts index dc7053e8..14b74fe4 100644 --- a/packages/frappe-ui-react/src/components/index.ts +++ b/packages/frappe-ui-react/src/components/index.ts @@ -4,6 +4,7 @@ export * from "./avatar"; export * from "./badge"; export * from "./breadcrumbs"; export * from "./button"; +export * from "./buttonGroup"; export * from "./calendar"; export * from "./charts"; export * from "./checkbox"; @@ -23,13 +24,17 @@ export * from "./listview"; export * from "./password"; export * from "./progress"; export * from "./popover"; +export * from "./radioButton"; export * from "./rating"; export * from "./select"; export * from "./sidebar"; +export * from "./slider"; +export * from "./spacer"; export * from "./spinner"; export * from "./switch"; export { default as TabButtons } from "./tabButtons"; export * from "./tabs"; +export * from "./tag"; export * from "./textInput"; export * from "./textarea"; export { default as TextEditor } from "./textEditor"; diff --git a/packages/frappe-ui-react/src/components/radioButton/index.ts b/packages/frappe-ui-react/src/components/radioButton/index.ts new file mode 100644 index 00000000..5d369661 --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/index.ts @@ -0,0 +1,2 @@ +export { default as RadioButton } from "./radioButton"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/radioButton/radioButton.stories.tsx b/packages/frappe-ui-react/src/components/radioButton/radioButton.stories.tsx new file mode 100644 index 00000000..8b28bc6a --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/radioButton.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import RadioButton from "./radioButton"; +import type { RadioButtonProps } from "./types"; +import { useState } from "react"; + +export default { + title: "Components/RadioButton", + component: RadioButton, + tags: ["autodocs"], + argTypes: { + className: { + control: "text", + description: + "Additional CSS classes for the radio button group container", + }, + size: { + control: { type: "select", options: ["sm", "md"] }, + description: "Size of the buttons in the group", + }, + flow: { + control: { type: "select", options: ["row", "column"] }, + description: "Layout flow of the radio buttons", + }, + disabled: { + control: "boolean", + description: "Disables all radio buttons when set to true", + }, + options: { + control: "object", + description: "Array of radio button options", + }, + value: { + control: "text", + description: "Currently selected value", + }, + onChange: { + action: "changed", + description: "Callback function when a radio button is selected", + }, + }, + parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + options: [ + { label: "John Doe", value: "john-doe" }, + { label: "Jane Smith", value: "jane-smith" }, + { label: "Bob Wilson", value: "bob-wilson" }, + ], + value: "john-doe", + size: "sm", + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; + +export const WithoutLabel: Story = { + args: { + options: [{ value: "option1" }, { value: "option2" }, { value: "option3" }], + value: "option1", + size: "sm", + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; + +export const Horizontal: Story = { + args: { + options: [ + { label: "John Doe", value: "john-doe" }, + { label: "Jane Smith", value: "jane-smith" }, + { label: "Bob Wilson", value: "bob-wilson" }, + ], + value: "john-doe", + size: "sm", + flow: "row", + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; + +export const DisabledOptions: Story = { + args: { + options: [ + { label: "John Doe", value: "john-doe" }, + { label: "Jane Smith", value: "jane-smith", disabled: true }, + { label: "Bob Wilson", value: "bob-wilson" }, + ], + value: "john-doe", + size: "sm", + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; + +export const GroupDisabled: Story = { + args: { + options: [ + { label: "John Doe", value: "john-doe" }, + { label: "Jane Smith", value: "jane-smith" }, + { label: "Bob Wilson", value: "bob-wilson" }, + ], + value: "john-doe", + size: "sm", + disabled: true, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+ ); + }, +}; diff --git a/packages/frappe-ui-react/src/components/radioButton/radioButton.tsx b/packages/frappe-ui-react/src/components/radioButton/radioButton.tsx new file mode 100644 index 00000000..5e543ca5 --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/radioButton.tsx @@ -0,0 +1,93 @@ +/** + * External dependencies. + */ +import { useMemo } from "react"; +import { Field, Label, Radio, RadioGroup } from "@headlessui/react"; +import clsx from "clsx"; + +/** + * Internal dependencies. + */ +import { RadioButtonProps } from "./types"; + +const RadioButton = ({ + options, + size = "sm", + flow = "column", + disabled = false, + className, + value, + onChange, +}: RadioButtonProps) => { + const hasAnyLabel = useMemo( + () => options.some((option) => option.label), + [options] + ); + + return ( + onChange?.(val)} + disabled={disabled} + className={clsx( + "flex gap-1", + flow === "row" && "flex-row", + flow === "column" && "flex-col items-start", + className + )} + > + {options.map((option) => ( + + + + ))} + + ); +}; + +export default RadioButton; diff --git a/packages/frappe-ui-react/src/components/radioButton/tests/radioButton.tsx b/packages/frappe-ui-react/src/components/radioButton/tests/radioButton.tsx new file mode 100644 index 00000000..c2492f28 --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/tests/radioButton.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import RadioButton from "../radioButton"; +import userEvent from "@testing-library/user-event"; + +describe("RadioButton Component", () => { + const options = [ + { label: "Option 1", value: "opt1" }, + { label: "Option 2", value: "opt2" }, + { label: "Option 3", value: "opt3", disabled: true }, + ]; + + it("renders all options", () => { + render(); + options.forEach((option) => + expect(screen.getByText(option.label)).toBeInTheDocument() + ); + }); + + it("handles selection change", async () => { + const handleChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByText("Option 2")); + expect(handleChange).toHaveBeenCalledWith("opt2"); + }); + + it("renders correctly controlled value", async () => { + const user = userEvent.setup(); + + const Wrapper = () => { + const [value, setValue] = React.useState("opt1"); + return ( + setValue(val || "")} + /> + ); + }; + + render(); + + const radio1 = screen.getByRole("radio", { name: "Option 1" }); + const radio2 = screen.getByRole("radio", { name: "Option 2" }); + + expect(radio1).toBeChecked(); + expect(radio2).not.toBeChecked(); + + // Click another option + await user.click(screen.getByText("Option 2")); + + expect(radio1).not.toBeChecked(); + expect(radio2).toBeChecked(); + }); + + it("respects global disabled state", async () => { + const handleChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + const radio1 = screen.getByRole("radio", { name: "Option 1" }); + expect(radio1).toHaveAttribute("aria-disabled", "true"); + + await user.click(screen.getByText("Option 1")); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it("respects individual option disabled state", async () => { + const handleChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + const radio3 = screen.getByRole("radio", { name: "Option 3" }); + expect(radio3).toHaveAttribute("aria-disabled", "true"); + + // Try clicking disabled option + await user.click(screen.getByText("Option 3")); + expect(handleChange).not.toHaveBeenCalled(); + + // Valid option still works + await user.click(screen.getByText("Option 1")); + expect(handleChange).toHaveBeenCalledWith("opt1"); + }); +}); diff --git a/packages/frappe-ui-react/src/components/radioButton/types.ts b/packages/frappe-ui-react/src/components/radioButton/types.ts new file mode 100644 index 00000000..611dcc28 --- /dev/null +++ b/packages/frappe-ui-react/src/components/radioButton/types.ts @@ -0,0 +1,15 @@ +export interface RadioButtonOption { + label?: string; + value: string; + disabled?: boolean; +} + +export interface RadioButtonProps { + options: RadioButtonOption[]; + size?: "sm" | "md"; + flow?: "column" | "row"; + disabled?: boolean; + className?: string; + value?: string | null; + onChange?: (value: string | null) => void; +} diff --git a/packages/frappe-ui-react/src/components/slider/index.ts b/packages/frappe-ui-react/src/components/slider/index.ts new file mode 100644 index 00000000..a721cc50 --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/index.ts @@ -0,0 +1,2 @@ +export { default as Slider } from "./slider"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/slider/slider.stories.tsx b/packages/frappe-ui-react/src/components/slider/slider.stories.tsx new file mode 100644 index 00000000..9037d030 --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/slider.stories.tsx @@ -0,0 +1,149 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import Slider from "./slider"; +import type { SliderProps, SliderRangeValue, SliderSingleValue } from "./types"; + +export default { + title: "Components/Slider", + component: Slider, + tags: ["autodocs"], + argTypes: { + min: { control: "number", description: "Minimum value of the slider" }, + max: { control: "number", description: "Maximum value of the slider" }, + step: { control: "number", description: "Step value for the slider" }, + range: { control: "boolean", description: "Enable range selection" }, + knob: { control: "boolean", description: "Show knob on the slider" }, + tooltip: { control: "boolean", description: "Show tooltip on the knob" }, + showValue: { control: "boolean", description: "Display current value" }, + size: { + control: { type: "select", options: ["sm", "md", "lg", "xl"] }, + description: "Size of the slider", + }, + value: { control: "object", description: "Current value of the slider" }, + disabled: { control: "boolean", description: "Disable the slider" }, + onChange: { + action: "changed", + description: "Callback function when the slider value changes", + }, + }, + parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + min: 0, + max: 100, + value: 70, + range: false, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ setValue(newValue)} + /> +

+ Value: {value as SliderSingleValue} +

+
+ ); + }, +}; + +export const Range: Story = { + args: { + min: 0, + max: 100, + value: { min: 30, max: 70 }, + range: true, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+

+ Min value: {(value as SliderRangeValue)?.min} +

+

+ Max value: {(value as SliderRangeValue)?.max} +

+
+
+ ); + }, +}; + +export const WithTooltip: Story = { + args: { + min: 0, + max: 100, + value: { min: 20, max: 80 }, + range: true, + tooltip: true, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +
+

+ Min value: {(value as SliderRangeValue)?.min} +

+

+ Max value: {(value as SliderRangeValue)?.max} +

+
+
+ ); + }, +}; + +export const WithValues: Story = { + args: { + min: 0, + max: 100, + value: 70, + tooltip: true, + showValue: true, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +

+ Value: {value as SliderSingleValue} +

+
+ ); + }, +}; + +export const WithoutKnob: Story = { + args: { + min: 0, + max: 100, + value: 70, + knob: false, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + return ( +
+ +

+ Value: {value as SliderSingleValue} +

+
+ ); + }, +}; diff --git a/packages/frappe-ui-react/src/components/slider/slider.tsx b/packages/frappe-ui-react/src/components/slider/slider.tsx new file mode 100644 index 00000000..2baa7222 --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/slider.tsx @@ -0,0 +1,278 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { SliderProps } from "./types"; +import clsx from "clsx"; +import Tooltip from "../tooltip/tooltip"; + +const Slider = ({ + min, + max, + value, + step = 1, + range = false, + knob = true, + tooltip = false, + showValue = false, + size = "md", + disabled = false, + className, + onChange, +}: SliderProps) => { + const [minVal, setMinVal] = useState( + typeof value === "object" ? value.min : value ?? min + ); + const [maxVal, setMaxVal] = useState( + typeof value === "object" ? value.max : max + ); + const [showMinTooltip, setShowMinTooltip] = useState(false); + const [showMaxTooltip, setShowMaxTooltip] = useState(false); + const rangeRef = useRef(null); + const trackRef = useRef(null); + const tooltipHelperRef = useRef(null); + const [thumbWidth, setThumbWidth] = useState(16); + + const trackHeightClasses = { + sm: "h-0.5", + md: "h-1", + lg: "h-2", + xl: "h-2.5", + }[size]; + + const fontSizeClasses = { + sm: "text-xs", + md: "text-xs", + lg: "text-base", + xl: "text-base", + }[size]; + + const thumbSizeClasses = { + sm: clsx( + "[&::-webkit-slider-thumb]:h-3.5", + "[&::-webkit-slider-thumb]:w-3.5", + "[&::-moz-range-thumb]:h-3.5", + "[&::-moz-range-thumb]:w-3.5" + ), + md: clsx( + "[&::-webkit-slider-thumb]:h-4", + "[&::-webkit-slider-thumb]:w-4", + "[&::-moz-range-thumb]:h-4", + "[&::-moz-range-thumb]:w-4" + ), + lg: clsx( + "[&::-webkit-slider-thumb]:h-5", + "[&::-webkit-slider-thumb]:w-5", + "[&::-moz-range-thumb]:h-5", + "[&::-moz-range-thumb]:w-5" + ), + xl: clsx( + "[&::-webkit-slider-thumb]:h-6", + "[&::-webkit-slider-thumb]:w-6", + "[&::-moz-range-thumb]:h-6", + "[&::-moz-range-thumb]:w-6" + ), + }[size]; + + const tooltipHelperClasses = { + sm: "h-3.5 w-3.5", + md: "h-4 w-4", + lg: "h-5 w-5", + xl: "h-6 w-6", + }[size]; + + const getPercent = useCallback( + (value: number) => { + if (!trackRef.current) { + return ((value - min) / (max - min)) * 100; + } + + const trackWidth = trackRef.current.offsetWidth; + const percent = (value - min) / (max - min); + const availableWidth = trackWidth - thumbWidth; + const thumbOffset = thumbWidth / 2; + + return ((percent * availableWidth + thumbOffset) / trackWidth) * 100; + }, + [min, max, thumbWidth] + ); + + // Measure thumb width to position tooltips correctly + useEffect(() => { + if (!tooltipHelperRef.current || !trackRef.current) return; + + if (!knob) { + setThumbWidth(0); + return; + } + + const width = tooltipHelperRef.current.offsetWidth; + setThumbWidth(width); + }, [size, knob]); + + const thumbClasses = clsx( + "w-full absolute outline-none appearance-none pointer-events-none", + "[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:pointer-events-auto", + knob + ? disabled + ? "[&::-webkit-slider-thumb]:bg-surface-gray-4" + : "[&::-webkit-slider-thumb]:bg-white" + : "[&::-webkit-slider-thumb]:bg-transparent", + knob && "[&::-webkit-slider-thumb]:border-none", + knob && "[&::-webkit-slider-thumb]:rounded-full", + knob && "[&::-webkit-slider-thumb]:shadow-[0_0_1px_1px_#ced4da]", + !disabled && knob && "[&::-webkit-slider-thumb]:cursor-pointer", + knob && "[&::-webkit-slider-thumb]:relative", + + "[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:pointer-events-auto", + knob + ? disabled + ? "[&::-moz-range-thumb]:bg-surface-gray-4" + : "[&::-moz-range-thumb]:bg-white" + : "[&::-moz-range-thumb]:bg-transparent", + knob && "[&::-moz-range-thumb]:border-none", + knob && "[&::-moz-range-thumb]:rounded-full", + knob && "[&::-moz-range-thumb]:shadow-[0_0_1px_1px_#ced4da]", + !disabled && knob && "[&::-moz-range-thumb]:cursor-pointer", + knob && "[&::-moz-range-thumb]:relative", + thumbSizeClasses, + trackHeightClasses + ); + + useEffect(() => { + if (!range) return; + + const minPercent = getPercent(minVal); + const maxPercent = getPercent(maxVal); + + if (rangeRef.current) { + rangeRef.current.style.left = `${minPercent}%`; + rangeRef.current.style.width = `${maxPercent - minPercent}%`; + } + }, [minVal, maxVal, getPercent, range]); + + useEffect(() => { + if (range) { + onChange?.({ min: minVal, max: maxVal }); + } else { + onChange?.(minVal); + } + }, [minVal, maxVal, onChange, range]); + + const handleMinChange = (event: React.ChangeEvent) => { + const newValue = range + ? Math.min(Number(event.target.value), maxVal - step) + : Number(event.target.value); + setMinVal(newValue); + }; + + const handleMaxChange = (event: React.ChangeEvent) => { + const newValue = Math.max(Number(event.target.value), minVal + step); + setMaxVal(newValue); + }; + + return ( +
+
+
+ {range && ( + +
+
+
+ + )} + +
+
+
+ +
+ {range && ( + setShowMinTooltip(true)} + onPointerUp={() => setShowMinTooltip(false)} + onPointerLeave={() => setShowMinTooltip(false)} + disabled={disabled} + className={clsx(thumbClasses, minVal > max - 100 ? "z-5" : "z-3")} + aria-label="Minimum value" + /> + )} + setShowMaxTooltip(true)} + onPointerUp={() => setShowMaxTooltip(false)} + onPointerLeave={() => setShowMaxTooltip(false)} + disabled={disabled} + className={clsx(thumbClasses, "z-4")} + aria-label={range ? "Maximum value" : "Value"} + /> +
+
+
+
+
+ {showValue && ( +
+
{min}
+
{max}
+
+ )} +
+ ); +}; + +export default Slider; diff --git a/packages/frappe-ui-react/src/components/slider/tests/slider.tsx b/packages/frappe-ui-react/src/components/slider/tests/slider.tsx new file mode 100644 index 00000000..8812b44c --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/tests/slider.tsx @@ -0,0 +1,161 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Slider from "../slider"; + +// Mock ResizeObserver globally +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +describe("Slider Component", () => { + describe("Single value slider", () => { + it("renders with required props and attributes", () => { + render(); + const slider = screen.getByRole("slider"); + expect(slider).toBeInTheDocument(); + expect(slider).toHaveValue("50"); + expect(slider).toHaveAttribute("min", "0"); + expect(slider).toHaveAttribute("max", "100"); + expect(slider).toHaveAttribute("step", "1"); + }); + + it("handles value change", () => { + const handleChange = jest.fn(); + render(); + + const slider = screen.getByRole("slider"); + fireEvent.change(slider, { target: { value: "75" } }); + + expect(slider).toHaveValue("75"); + expect(handleChange).toHaveBeenCalledWith(75); + }); + + it("displays min/max values when showValue is true", () => { + render(); + + expect(screen.getByText("0")).toBeInTheDocument(); + expect(screen.getByText("100")).toBeInTheDocument(); + }); + }); + + describe("Range slider", () => { + it("renders two inputs with correct values", () => { + render(); + + const minSlider = screen.getByLabelText("Minimum value"); + const maxSlider = screen.getByLabelText("Maximum value"); + + expect(minSlider).toHaveValue("25"); + expect(maxSlider).toHaveValue("75"); + }); + + it("prevents min value from exceeding max value", () => { + const handleChange = jest.fn(); + render( + + ); + + const minSlider = screen.getByLabelText("Minimum value"); + fireEvent.change(minSlider, { target: { value: "80" } }); + + expect(handleChange).toHaveBeenCalledWith( + expect.objectContaining({ min: 74, max: 75 }) + ); + }); + + it("prevents max value from going below min value", () => { + const handleChange = jest.fn(); + render( + + ); + + const maxSlider = screen.getByLabelText("Maximum value"); + fireEvent.change(maxSlider, { target: { value: "20" } }); + + expect(handleChange).toHaveBeenCalledWith( + expect.objectContaining({ min: 25, max: 26 }) + ); + }); + }); + + describe("Disabled state", () => { + it("disables slider input", () => { + render(); + const slider = screen.getByRole("slider"); + expect(slider).toBeDisabled(); + }); + }); + + describe("Tooltip functionality", () => { + it("shows and hides tooltip on pointer events", async () => { + render(); + + const input = screen.getByLabelText("Value"); + fireEvent.pointerDown(input); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent("50"); + + fireEvent.pointerUp(input); + + await waitFor(() => { + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + + it("does not show tooltip when disabled", () => { + render(); + + const input = screen.getByLabelText("Value"); + fireEvent.pointerDown(input); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + + describe("Knob visibility", () => { + it("hides knob when knob={false}", () => { + const { container } = render( + + ); + const sliderInput = container.querySelector('input[type="range"]'); + + expect(sliderInput).toHaveClass( + "[&::-webkit-slider-thumb]:bg-transparent" + ); + expect(sliderInput).toHaveClass("[&::-moz-range-thumb]:bg-transparent"); + }); + }); + + describe("Size variants", () => { + it("applies size classes correctly", () => { + const { container, rerender } = render( + + ); + let sliderInput = container.querySelector('input[type="range"]'); + + expect(sliderInput).toHaveClass("[&::-webkit-slider-thumb]:h-3.5"); + + rerender(); + sliderInput = container.querySelector('input[type="range"]'); + + expect(sliderInput).toHaveClass("[&::-webkit-slider-thumb]:h-6"); + }); + }); +}); diff --git a/packages/frappe-ui-react/src/components/slider/types.ts b/packages/frappe-ui-react/src/components/slider/types.ts new file mode 100644 index 00000000..63993423 --- /dev/null +++ b/packages/frappe-ui-react/src/components/slider/types.ts @@ -0,0 +1,18 @@ +export type SliderSingleValue = number; +export type SliderRangeValue = { min: number; max: number }; +export type SliderValue = SliderSingleValue | SliderRangeValue; + +export interface SliderProps { + min: number; + max: number; + step?: number; + range?: boolean; + knob?: boolean; + tooltip?: boolean; + showValue?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; + value?: SliderValue; // number for single value, object for range. + disabled?: boolean; + className?: string; + onChange?: (value: SliderValue) => void; +} diff --git a/packages/frappe-ui-react/src/components/spacer/index.ts b/packages/frappe-ui-react/src/components/spacer/index.ts new file mode 100644 index 00000000..c52d2e70 --- /dev/null +++ b/packages/frappe-ui-react/src/components/spacer/index.ts @@ -0,0 +1,2 @@ +export { default as Spacer } from "./spacer"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/spacer/spacer.stories.tsx b/packages/frappe-ui-react/src/components/spacer/spacer.stories.tsx new file mode 100644 index 00000000..ecdbcb62 --- /dev/null +++ b/packages/frappe-ui-react/src/components/spacer/spacer.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import Spacer from "./spacer"; +import type { SpacerProps } from "./types"; + +export default { + title: "Components/Spacer", + component: Spacer, + tags: ["autodocs"], + argTypes: { + orientation: { + control: "select", + options: ["horizontal", "vertical"], + description: "The orientation of the spacer.", + }, + size: { + control: "number", + description: "The size of the spacer in pixels.", + }, + }, + parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + orientation: "horizontal", + size: 100, + }, + render: (args) => ( +
+

Element Above Spacer

+ +

Element Below Spacer

+
+ ), +}; + +export const Vertical: Story = { + args: { + orientation: "vertical", + size: 100, + }, + render: (args) => ( +
+

Element Left of Spacer

+ +

Element Right of Spacer

+
+ ), +}; diff --git a/packages/frappe-ui-react/src/components/spacer/spacer.tsx b/packages/frappe-ui-react/src/components/spacer/spacer.tsx new file mode 100644 index 00000000..525d7cb4 --- /dev/null +++ b/packages/frappe-ui-react/src/components/spacer/spacer.tsx @@ -0,0 +1,16 @@ +import { SpacerProps } from "./types"; + +const Spacer = ({ orientation = "horizontal", size = 16 }: SpacerProps) => { + return ( +
+ ); +}; + +export default Spacer; diff --git a/packages/frappe-ui-react/src/components/spacer/tests/spacer.tsx b/packages/frappe-ui-react/src/components/spacer/tests/spacer.tsx new file mode 100644 index 00000000..33ba5052 --- /dev/null +++ b/packages/frappe-ui-react/src/components/spacer/tests/spacer.tsx @@ -0,0 +1,29 @@ +import { render } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Spacer from "../spacer"; + +describe("Spacer Component", () => { + it("renders with default props", () => { + const { container } = render(); + const spacer = container.firstChild as HTMLElement; + + expect(spacer).toHaveClass("w-full"); + expect(spacer).toHaveStyle({ height: "16px", minHeight: "16px" }); + }); + + it("renders horizontal spacer with custom size", () => { + const { container } = render(); + const spacer = container.firstChild as HTMLElement; + + expect(spacer).toHaveClass("w-full"); + expect(spacer).toHaveStyle({ height: "24px", minHeight: "24px" }); + }); + + it("renders vertical spacer with custom size", () => { + const { container } = render(); + const spacer = container.firstChild as HTMLElement; + + expect(spacer).toHaveClass("h-full"); + expect(spacer).toHaveStyle({ width: "32px", minWidth: "32px" }); + }); +}); diff --git a/packages/frappe-ui-react/src/components/spacer/types.ts b/packages/frappe-ui-react/src/components/spacer/types.ts new file mode 100644 index 00000000..a5268312 --- /dev/null +++ b/packages/frappe-ui-react/src/components/spacer/types.ts @@ -0,0 +1,4 @@ +export interface SpacerProps { + orientation?: "horizontal" | "vertical"; + size?: number; +} 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..4d8570bf --- /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 { 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..4a58684f --- /dev/null +++ b/packages/frappe-ui-react/src/components/tag/types.ts @@ -0,0 +1,16 @@ +import { 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; +} diff --git a/packages/frappe-ui-react/src/components/tooltip/tooltip.tsx b/packages/frappe-ui-react/src/components/tooltip/tooltip.tsx index df93d77c..e71e964e 100644 --- a/packages/frappe-ui-react/src/components/tooltip/tooltip.tsx +++ b/packages/frappe-ui-react/src/components/tooltip/tooltip.tsx @@ -17,6 +17,7 @@ const TooltipComponent: React.FC = ({ hoverDelay = 0.5, arrowClass = "fill-surface-gray-7", disabled = false, + open, }) => { const delayDuration = useMemo(() => hoverDelay * 1000, [hoverDelay]); @@ -42,7 +43,7 @@ const TooltipComponent: React.FC = ({ return ( - + {children} {tooltipContent && ( diff --git a/packages/frappe-ui-react/src/components/tooltip/types.ts b/packages/frappe-ui-react/src/components/tooltip/types.ts index b7033d25..83b61b89 100644 --- a/packages/frappe-ui-react/src/components/tooltip/types.ts +++ b/packages/frappe-ui-react/src/components/tooltip/types.ts @@ -10,4 +10,5 @@ export interface TooltipProps { hoverDelay?: number; // In seconds arrowClass?: string; disabled?: boolean; + open?: boolean; }