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
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,37 @@ const FormControl: React.FC<FormControlProps> = ({

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 (
<Select
htmlId={htmlId}
{...controlAttrs}
size={size}
variant={variant}
options={controlAttrs.options as (string | SelectOption)[]}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
controlAttrs.onChange?.(e.target.value);
options={normalizedOptions}
value={controlAttrs.value as SelectOption | undefined}
placeholder={controlAttrs.placeholder}
disabled={controlAttrs.disabled}
loading={controlAttrs.loading}
prefix={controlAttrs.prefix as React.ReactNode}
suffix={controlAttrs.suffix as React.ReactNode}
onChange={(selected: SelectOption) => {
controlAttrs.onChange?.(selected.value);
}}
/>
);
}

case "autocomplete":
return (
<Autocomplete
Expand All @@ -56,6 +74,7 @@ const FormControl: React.FC<FormControlProps> = ({
{...controlAttrs}
/>
);

case "textarea":
return (
<Textarea
Expand All @@ -65,6 +84,7 @@ const FormControl: React.FC<FormControlProps> = ({
variant={variant}
/>
);

case "checkbox":
return (
<Checkbox
Expand All @@ -74,6 +94,7 @@ const FormControl: React.FC<FormControlProps> = ({
size={size}
/>
);

default:
return (
<TextInput
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { userEvent, expect, within } from "storybook/test";
import Select from "./select";
import type { SelectOption } from "./types";

const meta: Meta<typeof Select> = {
title: "Components/Select/Interactions",
component: Select,
parameters: {
docs: { source: { type: "dynamic" } },
layout: "centered",
},
};

export default meta;
type Story = StoryObj<typeof Select>;

const options: SelectOption[] = [
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: "2" },
{ label: "Option 3", value: "3", disabled: true },
];

export const ControlledSelect: Story = {
render: () => {
const [selected, setSelected] = useState<SelectOption | undefined>();
return (
<div className="w-72">
<Select
options={options}
value={selected}
onChange={setSelected}
placeholder="Select option"
/>
</div>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");

await userEvent.click(button);
const listbox = canvas.getByRole("listbox");
expect(listbox).toBeInTheDocument();

const allOptions = canvas.getAllByRole("option");
expect(allOptions).toHaveLength(3);

const disabledOption = canvas.getByRole("option", { name: "Option 3" });
expect(disabledOption).toHaveAttribute("aria-disabled", "true");
await userEvent.click(disabledOption);
expect(button).not.toHaveTextContent("Option 3");

const option2 = canvas.getByText("Option 2");
await userEvent.click(option2);
expect(button).toHaveTextContent("Option 2");
expect(canvas.queryByRole("listbox")).not.toBeInTheDocument();
},
};

export const DisabledState: Story = {
args: {
options,
disabled: true,
placeholder: "Disabled select",
},
render: (args) => (
<div className="w-72">
<Select {...args} />
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
expect(button).toBeDisabled();
},
};

export const LoadingState: Story = {
args: {
options,
loading: true,
placeholder: "Loading select",
},
render: (args) => (
<div className="w-72">
<Select {...args} />
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
expect(button).toBeDisabled();
const spinner = canvas.getByLabelText("loading");
expect(spinner).toBeInTheDocument();
},
};
export const VariantStyling: Story = {
args: {
options,
variant: "outline",
placeholder: "Outline select",
},
render: (args) => (
<div className="w-72">
<Select {...args} />
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
expect(button).toHaveClass("border-outline-gray-2");
},
};

export const WithPrefixSuffix: Story = {
args: {
options,
prefix: <span>Prefix</span>,
suffix: <span>Suffix</span>,
placeholder: "Select option",
},
render: (args) => {
const [selected, setSelected] = useState<SelectOption | undefined>();
return (
<div className="w-72">
<Select {...args} value={selected} onChange={setSelected} />
</div>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Prefix")).toBeInTheDocument();
expect(canvas.getByText("Suffix")).toBeInTheDocument();
const button = canvas.getByRole("button");
await userEvent.click(button);
const option1 = canvas.getByText("Option 1");
await userEvent.click(option1);
expect(button).toHaveTextContent("Option 1");
},
};

export const Placeholder: Story = {
args: {
options,
placeholder: "Pick one...",
},
render: (args) => (
<div className="w-72">
<Select {...args} />
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
expect(button).toHaveTextContent("Pick one...");
},
};
119 changes: 78 additions & 41 deletions packages/frappe-ui-react/src/components/select/select.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Select> = {
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<typeof Select>;
};

const Template: StoryObj<SelectProps> = {
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<typeof Select>;

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<SelectOption | undefined>(
args.value
);
return (
<div className="p-4 w-[300px]">
<div className="w-72">
<Select
{...args}
value={value}
onChange={(e) => setValue(e.target.value)}
value={selected}
onChange={(val) => {
setSelected(val);
args.onChange?.(val);
}}
/>
</div>
);
},
};

export const Default = {
export const Subtle: Story = {
...Template,
args: {
variant: "subtle",
placeholder: "Select option",
options,
},
};

export const Outline: Story = {
...Template,
args: {
variant: "outline",
placeholder: "Select option",
options,
},
};

export const WithPrefix = {
export const Ghost: Story = {
...Template,
args: {
...Template.args,
prefix: () => <User size={16} className="text-ink-gray-9" />,
variant: "ghost",
placeholder: "Select option",
options,
},
};
Loading