From 51dec5e8cc2c078af54d5be3e7cd0be9de78564a Mon Sep 17 00:00:00 2001 From: Swayam kale Date: Sat, 7 Feb 2026 17:08:47 +0530 Subject: [PATCH 1/4] feat(select) --- .../src/components/select/select.stories.tsx | 119 +++++--- .../src/components/select/select.tsx | 261 ++++++++++-------- .../src/components/select/tests/select.tsx | 67 ++--- .../src/components/select/types.ts | 17 +- 4 files changed, 262 insertions(+), 202 deletions(-) diff --git a/packages/frappe-ui-react/src/components/select/select.stories.tsx b/packages/frappe-ui-react/src/components/select/select.stories.tsx index f8259e7c..8246da8c 100644 --- a/packages/frappe-ui-react/src/components/select/select.stories.tsx +++ b/packages/frappe-ui-react/src/components/select/select.stories.tsx @@ -1,90 +1,127 @@ -import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { SelectProps } from "./types"; +import { useState } from "react"; import Select from "./select"; -import { User } from "lucide-react"; +import type { SelectOption } from "./types"; -export default { +const meta: Meta = { title: "Components/Select", component: Select, - parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, + parameters: { + docs: { source: { type: "dynamic" } }, + layout: "centered", + }, tags: ["autodocs"], argTypes: { size: { - control: { type: "select", options: ["sm", "md", "lg"] }, + options: ["sm", "md", "lg"], + control: { type: "select" }, description: "Size of the select input", }, variant: { - control: { type: "select", options: ["outline", "subtle"] }, - description: "Visual variant of the select input", + options: ["subtle", "outline", "ghost"], + control: { type: "select" }, + description: "Visual variant of the select component", + }, + state: { + options: [undefined, "success", "warning", "error"], + control: { type: "select" }, + description: "Validation / feedback state", }, disabled: { control: "boolean", - description: "If true, disables the select input", + description: "Disables the select input", }, - value: { - control: "text", - description: "Current value of the select input", + loading: { + control: "boolean", + description: "Shows loading indicator and disables the select", }, placeholder: { control: "text", - description: "Placeholder text when no value is selected", + description: "Placeholder text shown when no value is selected", }, options: { control: "object", - description: - "Array of options to display in the dropdown, each with a label and value", + description: "List of selectable options", }, - prefix: { + value: { control: false, - description: "Element to display before the selected value", + description: "Currently selected option", }, htmlId: { control: "text", - description: "HTML id attribute for the select input", + description: "HTML id attribute for the select element", + }, + className: { + control: "text", + description: "Custom CSS class applied to the select wrapper", + }, + prefix: { + control: false, + description: "ReactNode rendered before the selected value", + }, + suffix: { + control: false, + description: "ReactNode rendered after the selected value", }, onChange: { action: "changed", - description: "Callback function when the selected value changes", + description: "Callback triggered when the selected value changes", }, }, -} as Meta; +}; -const Template: StoryObj = { - args: { - value: "", - options: [ - { label: "John Doe", value: "john-doe" }, - { label: "Jane Doe", value: "jane-doe" }, - { label: "John Smith", value: "john-smith" }, - { label: "Jane Smith", value: "jane-smith", disabled: true }, - { label: "John Wayne", value: "john-wayne" }, - { label: "Jane Wayne", value: "jane-wayne" }, - ], - }, - render: (args) => { - const [value, setValue] = useState(args.value || ""); +export default meta; +type Story = StoryObj; + +const options: SelectOption[] = [ + { label: "Option 1", value: "1" }, + { label: "Option 2", value: "2" }, + { label: "Option 3", value: "3" }, +]; +const Template: Story = { + render: (args) => { + const [selected, setSelected] = useState( + args.value + ); return ( -
+
- {placeholder && !value && - ))} - +
+ + ( + +
+ {prefix && ( + {prefix} + )} + + {value?.icon && ( + + {value.icon} + + )} + + + {value ? value.label : placeholder} + +
+ + {loading ? ( + + ) : ( + + {suffix ?? } + + )} +
+ )} + body={() => ( +
+ {options.map((option) => ( + + clsx( + "flex cursor-pointer items-center justify-between rounded px-2.5 py-1.5 text-base", + focus && "bg-surface-gray-3", + option.disabled && "opacity-50 cursor-not-allowed" + ) + } + > + {({ selected }) => ( + <> + + {option.icon && ( + {option.icon} + )} + {option.label} + + {selected && ( + + )} + + )} + + ))} +
+ )} + /> +
); }; diff --git a/packages/frappe-ui-react/src/components/select/tests/select.tsx b/packages/frappe-ui-react/src/components/select/tests/select.tsx index 974ba242..fc64ce27 100644 --- a/packages/frappe-ui-react/src/components/select/tests/select.tsx +++ b/packages/frappe-ui-react/src/components/select/tests/select.tsx @@ -1,78 +1,79 @@ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import Select from "../select"; +import type { SelectOption } from "../types"; + +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; describe("Select", () => { - const options = [ + const options: SelectOption[] = [ { label: "Option 1", value: "1" }, { label: "Option 2", value: "2" }, { label: "Option 3", value: "3", disabled: true }, ]; - it("renders all options", () => { - render( {}} /> ); + expect(screen.getByText("Select an option")).toBeInTheDocument(); }); it("renders with initial value", () => { - render( {}} />); + expect(screen.getByText("Option 2")).toBeInTheDocument(); }); - it("calls onChange when selecting option", () => { + it("calls onChange when selecting an option", async () => { const handleChange = jest.fn(); - render( + ); - it("is disabled when disabled prop is true", () => { - render( {}} - htmlId="custom-id" + disabled /> ); - expect(screen.getByRole("combobox")).toHaveAttribute("id", "custom-id"); + expect(screen.getByRole("button")).toBeDisabled(); }); it("renders prefix", () => { - const Prefix = () => Prefix; render( {}} />); - expect(screen.getByText("Option 3")).toBeDisabled(); + expect(screen.getByText("PrefixIcon")).toBeInTheDocument(); }); }); diff --git a/packages/frappe-ui-react/src/components/select/types.ts b/packages/frappe-ui-react/src/components/select/types.ts index 6c66f3a1..18575ad9 100644 --- a/packages/frappe-ui-react/src/components/select/types.ts +++ b/packages/frappe-ui-react/src/components/select/types.ts @@ -1,23 +1,28 @@ import type { ReactNode } from "react"; -export type SelectSize = "sm" | "md" | "lg" | "xl"; +export type SelectSize = "sm" | "md" | "lg"; export type SelectVariant = "subtle" | "outline" | "ghost"; +export type SelectState = "success" | "warning" | "error"; export interface SelectOption { value: string; label: string; + icon?: ReactNode; disabled?: boolean; } export interface SelectProps { size?: SelectSize; variant?: SelectVariant; + state?: SelectState; + loading?: boolean; disabled?: boolean; - value?: string; + value?: SelectOption; placeholder?: string; - options: (string | SelectOption)[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - prefix?: (args?: any) => ReactNode; + options: SelectOption[]; + prefix?: ReactNode; + suffix?: ReactNode; htmlId?: string; - onChange?: (event: React.ChangeEvent) => void; + onChange?: (value: SelectOption) => void; + className?: string; } From 8d0c32f1e8f2a032c25d1c8c0c59e0ad02b988ec Mon Sep 17 00:00:00 2001 From: Swayam kale Date: Sat, 14 Feb 2026 14:35:22 +0530 Subject: [PATCH 2/4] feat(select): refactor with cva and enhance interaction tests --- .../components/formControl/formControl.tsx | 31 ++- .../select/select.interactions.stories.tsx | 159 +++++++++++++ .../src/components/select/select.tsx | 223 +++++++++++++----- .../src/components/select/tests/select.tsx | 79 ------- 4 files changed, 347 insertions(+), 145 deletions(-) create mode 100644 packages/frappe-ui-react/src/components/select/select.interactions.stories.tsx delete mode 100644 packages/frappe-ui-react/src/components/select/tests/select.tsx diff --git a/packages/frappe-ui-react/src/components/formControl/formControl.tsx b/packages/frappe-ui-react/src/components/formControl/formControl.tsx index 7286ee51..04b61a1c 100644 --- a/packages/frappe-ui-react/src/components/formControl/formControl.tsx +++ b/packages/frappe-ui-react/src/components/formControl/formControl.tsx @@ -35,19 +35,37 @@ const FormControl: React.FC = ({ const renderControl = () => { switch (type) { - case "select": + case "select": { + const rawOptions = controlAttrs.options ?? []; + + const normalizedOptions: SelectOption[] = (rawOptions as unknown[]).map( + (option) => { + if (typeof option === "string") { + return { label: option, value: option }; + } + return option as SelectOption; + } + ); + return (