diff --git a/.prettierrc b/.prettierrc index 757fd64c..b02580b9 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,4 @@ { + "printWidth": 80, "trailingComma": "es5" } diff --git a/.storybook/main.ts b/.storybook/main.ts index 51e847f3..cb5038e9 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,34 +1,38 @@ +import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import type { StorybookConfig } from "@storybook/react-vite"; import path, { dirname, join } from "path"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + const require = createRequire(import.meta.url); +const isProduction = process.env.NODE_ENV === "production"; const config: StorybookConfig = { - stories: - process.env.NODE_ENV === "production" - ? [ - "../*.mdx", - "../**/src/**/*.mdx", - "../**/src/**/!(*.interactions).stories.@(js|jsx|mjs|ts|tsx)", - ] - : [ - "../*.mdx", - "../**/src/**/*.mdx", - "../**/src/**/*.stories.@(js|jsx|mjs|ts|tsx)", - ], - addons: - process.env.NODE_ENV === "production" - ? [ - getAbsolutePath("@storybook/addon-docs"), - getAbsolutePath("@storybook/addon-themes"), - ] - : [ - getAbsolutePath("@storybook/addon-docs"), - getAbsolutePath("@storybook/addon-a11y"), - getAbsolutePath("@storybook/addon-themes"), - getAbsolutePath("@storybook/addon-vitest"), - ], + stories: isProduction + ? [ + "../*.mdx", + "../**/src/**/*.mdx", + "../**/src/**/!(*.interactions|*.dev).stories.@(js|jsx|mjs|ts|tsx)", + ] + : [ + "../*.mdx", + "../**/src/**/*.mdx", + "../**/src/**/*.stories.@(js|jsx|mjs|ts|tsx)", + ], + addons: isProduction + ? [ + getAbsolutePath("@storybook/addon-docs"), + getAbsolutePath("@storybook/addon-themes"), + ] + : [ + getAbsolutePath("@storybook/addon-mcp"), + getAbsolutePath("@storybook/addon-docs"), + getAbsolutePath("@storybook/addon-a11y"), + getAbsolutePath("@storybook/addon-themes"), + getAbsolutePath("@storybook/addon-vitest"), + ], framework: { name: getAbsolutePath("@storybook/react-vite"), options: {}, @@ -57,7 +61,7 @@ const config: StorybookConfig = { }, }, features: { - interactions: process.env.NODE_ENV !== "production", + interactions: !isProduction, }, }; export default config; diff --git a/.storybook/manager.ts b/.storybook/manager.ts index a1076d95..5b8a81e1 100644 --- a/.storybook/manager.ts +++ b/.storybook/manager.ts @@ -5,7 +5,7 @@ addons.setConfig({ theme: create({ base: "light", brandTitle: - '

frappe-ui-react

(v1.2.0)', // update version as per package.json + '

frappe-ui-react

(v1.3.0)', // update version as per package.json brandUrl: undefined, // disables link on the title brandImage: undefined, diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 0073f389..c59f9e82 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -53,7 +53,7 @@ const preview: Preview = { }, options: { storySort: { - order: ["Getting Started", "Theme", "Components"], + order: ["Getting Started", "DesignSystem", "Theme", "Components"], }, }, }, diff --git a/GETTING-STARTED.md b/GETTING-STARTED.md index 7c61e68e..6d3abf39 100644 --- a/GETTING-STARTED.md +++ b/GETTING-STARTED.md @@ -2,7 +2,7 @@ Frappe UI React is a modern component library designed for building frontend app ## Prerequisites -- Node.js v20 +- Node.js v24 - TailwindCSS v4 ## Usage @@ -89,7 +89,7 @@ This library is built on top of several excellent open-source projects: - **[TailwindCSS](https://github.com/tailwindlabs/tailwindcss)**: Utility-first CSS framework for building design system-based UIs. - **[Headless UI](https://github.com/tailwindlabs/headlessui)**: Unstyled and accessible UI components. -- **[Radix UI](https://github.com/radix-ui/themes)**: Low-level, unstyled, and accessible UI primitives. +- **[Base UI](https://base-ui.com/)**: Low-level, unstyled, and accessible UI primitives. - **[dayjs](https://github.com/iamkun/dayjs)**: Lightweight JavaScript library for working with dates. - **[TipTap](https://tiptap.dev/docs)**: Headless Text Editor diff --git a/README.md b/README.md index ec9a60d5..27d88df9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Frappe UI React is a modern component library designed for building frontend app ## Prerequisites -- Node.js v20 +- Node.js v24 - TailwindCSS v4 ## Usage @@ -95,7 +95,7 @@ This library is built on top of several excellent open-source projects: - **[TailwindCSS](https://github.com/tailwindlabs/tailwindcss)**: Utility-first CSS framework for building design system-based UIs. - **[Headless UI](https://github.com/tailwindlabs/headlessui)**: Unstyled and accessible UI components. -- **[Radix UI](https://github.com/radix-ui/themes)**: Low-level, unstyled, and accessible UI primitives. +- **[Base UI](https://base-ui.com/)**: Low-level, unstyled, and accessible UI primitives. - **[dayjs](https://github.com/iamkun/dayjs)**: Lightweight JavaScript library for working with dates. - **[TipTap](https://tiptap.dev/docs)**: Headless Text Editor diff --git a/package.json b/package.json index 7074b828..33f5f84e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frappe-ui-react", "private": true, - "version": "1.2.0", + "version": "1.3.0", "type": "module", "engines": { "npm": "please-use-pnpm", @@ -10,7 +10,7 @@ }, "scripts": { "prepare": "husky", - "dev": "vite", + "dev": "pnpm --filter @rtcamp/frappe-ui-react dev", "build": "pnpm --filter @rtcamp/frappe-ui-react build", "preview": "vite preview", "storybook": "storybook dev -p 6006", @@ -30,15 +30,16 @@ "format": "prettier --write \"./**/*.{js,jsx,mjs,cjs,ts,tsx,json}\"" }, "devDependencies": { - "@babel/preset-env": "^7.29.5", - "@babel/preset-react": "^7.29.5", - "@babel/preset-typescript": "^7.29.5", + "@babel/preset-env": "^7.29.7", + "@babel/preset-react": "^7.22.5", + "@babel/preset-typescript": "^7.22.5", "@eslint/js": "^9.30.1", - "@storybook/addon-a11y": "^9.1.19", - "@storybook/addon-docs": "^9.1.19", - "@storybook/addon-themes": "^9.1.19", - "@storybook/addon-vitest": "^9.1.19", - "@storybook/react-vite": "^9.1.19", + "@storybook/addon-a11y": "^10.3.5", + "@storybook/addon-docs": "^10.3.5", + "@storybook/addon-mcp": "^0.6.0", + "@storybook/addon-themes": "^10.3.5", + "@storybook/addon-vitest": "^10.3.5", + "@storybook/react-vite": "^10.3.5", "@tailwindcss/vite": "^4.1.11", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", @@ -56,7 +57,7 @@ "eslint-plugin-jest": "^29.12.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", - "eslint-plugin-storybook": "^9.1.19", + "eslint-plugin-storybook": "^10.3.5", "globals": "^17.0.0", "husky": "^9.1.7", "jest": "^30.1.3", @@ -68,10 +69,10 @@ "prettier": "^3.7.4", "react": "^19.2.4", "react-dom": "^19.2.4", - "storybook": "^9.1.19", + "storybook": "^10.3.5", "typescript": "~5.9.3", "typescript-eslint": "^8.53.0", - "vite": "^7.3.5", + "vite": "^7.3.6", "vite-plugin-svgr": "^4.3.0", "vitest": "^4.1.10" }, @@ -87,6 +88,7 @@ "*.yml": "prettier --write" }, "dependencies": { + "pnpm": "^10.34.5", "react-docgen-typescript": "^2.4.0" }, "packageManager": "pnpm@10.13.1", @@ -100,6 +102,7 @@ "brace-expansion@>=4.0.0 <5.0.7": "^5.0.7", "picomatch@>=4.0.0 <4.0.4": "^4.0.4", "yaml@>=2.0.0 <2.8.3": "^2.8.3", + "valibot@<1.4.2": "^1.4.2", "vite@>=7.0.0 <7.3.5": "^7.3.5", "postcss@<8.5.18": "^8.5.18", "vitest@<4.1.0": "^4.1.10", diff --git a/packages/frappe-ui-react/README.md b/packages/frappe-ui-react/README.md index f68619ae..e67d8c4d 100644 --- a/packages/frappe-ui-react/README.md +++ b/packages/frappe-ui-react/README.md @@ -89,7 +89,6 @@ This library is built on top of several excellent open-source projects: - **[TailwindCSS](https://github.com/tailwindlabs/tailwindcss)**: Utility-first CSS framework for building design system-based UIs. - **[Headless UI](https://github.com/tailwindlabs/headlessui)**: Unstyled and accessible UI components. -- **[Radix UI](https://github.com/radix-ui/themes)**: Low-level, unstyled, and accessible UI primitives. - **[Base UI](https://base-ui.com/)**: Low-level, unstyled, and accessible UI primitives. - **[dayjs](https://github.com/iamkun/dayjs)**: Lightweight JavaScript library for working with dates. - **[TipTap](https://tiptap.dev/docs)**: Headless Text Editor diff --git a/packages/frappe-ui-react/package.json b/packages/frappe-ui-react/package.json index 7817cbfc..ad5193cc 100644 --- a/packages/frappe-ui-react/package.json +++ b/packages/frappe-ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@rtcamp/frappe-ui-react", - "version": "1.2.0", + "version": "1.3.0", "main": "dist/index.js", "module": "dist/index.js", "types": "dist-types/index.d.ts", @@ -36,6 +36,7 @@ }, "scripts": { "build": "tsc -b && pnpm copy-assets", + "dev": "pnpm copy-assets && tsc -b -w", "copy-assets": "node scripts/copy-assets.js", "publish:local": "pnpm publish --registry http://localhost:4873", "unpublish:local": "pnpm unpublish --registry http://localhost:4873 --force", @@ -43,24 +44,30 @@ }, "dependencies": { "@base-ui/react": "^1.1.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@floating-ui/react": "^0.27.16", "@headlessui/react": "^2.2.9", "@popperjs/core": "^2.11.8", "@tailwindcss/typography": "^0.5.19", - "@tiptap/extension-blockquote": "^3.17.1", - "@tiptap/extension-code-block-lowlight": "^3.17.1", - "@tiptap/extension-highlight": "^3.17.1", - "@tiptap/extension-horizontal-rule": "^3.17.1", - "@tiptap/extension-list": "^3.17.1", - "@tiptap/extension-placeholder": "^3.17.1", - "@tiptap/extension-strike": "^3.17.1", - "@tiptap/extension-table": "^3.17.1", - "@tiptap/extension-task-list": "^3.17.1", - "@tiptap/extension-text-align": "^3.17.1", - "@tiptap/extension-text-style": "^3.17.1", - "@tiptap/pm": "^3.17.1", - "@tiptap/react": "^3.17.1", - "@tiptap/starter-kit": "^3.17.1", + "@tiptap/extension-blockquote": "^3.27.1", + "@tiptap/extension-code-block-lowlight": "^3.27.1", + "@tiptap/extension-highlight": "^3.27.1", + "@tiptap/extension-horizontal-rule": "^3.27.1", + "@tiptap/extension-list": "^3.27.1", + "@tiptap/extension-mention": "^3.27.1", + "@tiptap/extension-placeholder": "^3.27.1", + "@tiptap/extension-strike": "^3.27.1", + "@tiptap/extension-table": "^3.27.1", + "@tiptap/extension-task-list": "^3.27.1", + "@tiptap/extension-text-align": "^3.27.1", + "@tiptap/extension-text-style": "^3.27.1", + "@tiptap/pm": "^3.27.1", + "@tiptap/react": "^3.27.1", + "@tiptap/starter-kit": "^3.27.1", + "@tiptap/static-renderer": "^3.27.1", + "@tiptap/suggestion": "^3.27.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", diff --git a/packages/frappe-ui-react/src/components/autoComplete/autoComplete.interactions.stories.tsx b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.interactions.stories.tsx new file mode 100644 index 00000000..904c08ec --- /dev/null +++ b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.interactions.stories.tsx @@ -0,0 +1,368 @@ +import * as React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; + +import Autocomplete from "./autoComplete"; + +const meta: Meta = { + title: "Components/Autocomplete/Interactions", + component: Autocomplete, + parameters: { + docs: { source: { type: "dynamic" } }, + layout: "centered", + }, +}; + +export default meta; + +type Story = StoryObj; + +const simpleOptions = [ + { label: "Apple", value: "apple" }, + { label: "Banana", value: "banana" }, + { label: "Cherry", value: "cherry" }, +]; + +const directoryOptions = [ + { label: "John Doe", value: "john-doe", image: "john.png" }, + { label: "Jane Doe", value: "jane-doe", image: "jane.png" }, + { label: "John Smith", value: "john-smith", image: "smith.png" }, + { label: "Bob Johnson", value: "bob-johnson", image: "bob.png" }, +]; + +const groupedOptions = [ + { + group: "Fruits", + items: [ + { label: "Apple", value: "apple" }, + { label: "Banana", value: "banana", disabled: true }, + ], + }, + { + group: "Vegetables", + items: [{ label: "Carrot", value: "carrot" }], + }, +]; + +export const PreselectedDisplay: Story = { + args: { + options: directoryOptions, + value: "john-doe", + placeholder: "Select a person", + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("combobox", { name: /toggle options/i }); + + await waitFor(() => { + expect(trigger).toHaveTextContent("John Doe"); + }); + }, +}; + +export const OptionSelection: Story = { + args: { + options: simpleOptions, + value: null, + placeholder: "Select an item", + onChange: fn(), + }, + render: function Render(args) { + const [value, setValue] = React.useState(null); + + return ( +
+ { + setValue(nextValue as string | null); + args.onChange?.(nextValue, nextOption); + }} + /> +
+ ); + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const page = within(document.body); + const trigger = canvas.getByRole("combobox", { name: /toggle options/i }); + + await userEvent.click(trigger); + + const search = await page.findByPlaceholderText("Search"); + await userEvent.type(search, "Che"); + + const option = await page.findByText("Cherry"); + await userEvent.click(option); + + await expect(args.onChange).toHaveBeenCalledWith( + "cherry", + expect.objectContaining({ label: "Cherry", value: "cherry" }) + ); + + await waitFor(() => { + expect(trigger).toHaveTextContent("Cherry"); + }); + }, +}; + +export const GroupedAndDisabled: Story = { + args: { + options: groupedOptions, + value: null, + placeholder: "Select an item", + }, + render: function Render(args) { + const [value, setValue] = React.useState(null); + + return ( +
+ { + void _selectedOption; + setValue(nextValue as string | null); + }} + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const page = within(document.body); + const trigger = canvas.getByRole("combobox", { name: /toggle options/i }); + + await userEvent.click(trigger); + + await expect(page.findByText("Fruits")).resolves.toBeInTheDocument(); + await expect(page.findByText("Vegetables")).resolves.toBeInTheDocument(); + + const banana = await page.findByText("Banana"); + expect(banana.closest('[role="option"]')).toHaveAttribute("data-disabled"); + }, +}; + +export const MultipleSelectionFooter: Story = { + args: { + options: simpleOptions, + value: [], + multiple: true, + showFooter: true, + placeholder: "Select items", + onChange: fn(), + }, + render: function Render(args) { + const [value, setValue] = React.useState([]); + + return ( +
+ { + setValue((nextValue as string[]) ?? []); + args.onChange?.(nextValue, nextOptions); + }} + /> +
+ ); + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const page = within(document.body); + const trigger = canvas.getByRole("combobox", { name: /toggle options/i }); + + await userEvent.click(trigger); + await userEvent.click(await page.findByText("Apple")); + await userEvent.click(await page.findByText("Cherry")); + + await waitFor(() => { + expect(args.onChange).toHaveBeenLastCalledWith( + ["apple", "cherry"], + expect.arrayContaining([ + expect.objectContaining({ label: "Apple", value: "apple" }), + expect.objectContaining({ label: "Cherry", value: "cherry" }), + ]) + ); + }); + + await userEvent.click(page.getByRole("button", { name: "Select All" })); + + await waitFor(() => { + expect(args.onChange).toHaveBeenLastCalledWith( + ["apple", "banana", "cherry"], + expect.arrayContaining([ + expect.objectContaining({ label: "Apple", value: "apple" }), + expect.objectContaining({ label: "Banana", value: "banana" }), + expect.objectContaining({ label: "Cherry", value: "cherry" }), + ]) + ); + }); + + await userEvent.click(page.getByRole("button", { name: "Clear All" })); + + await waitFor(() => { + expect(args.onChange).toHaveBeenLastCalledWith([], []); + }); + }, +}; + +export const ControlledSearchLoading: Story = { + args: { + options: directoryOptions, + value: [], + multiple: true, + keepSelectedVisible: true, + placeholder: "Select people", + }, + render: function Render(args) { + const [value, setValue] = React.useState([]); + const [searchValue, setSearchValue] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [options, setOptions] = React.useState(directoryOptions); + + React.useEffect(() => { + let cancelled = false; + + setLoading(true); + + const timeoutId = window.setTimeout(() => { + if (cancelled) { + return; + } + + setOptions( + searchValue.trim() + ? directoryOptions.filter((option) => + option.label.toLowerCase().includes(searchValue.toLowerCase()) + ) + : directoryOptions + ); + setLoading(false); + }, 250); + + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + }; + }, [searchValue]); + + return ( +
+ { + setValue(_value as string[]); + }} + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const page = within(document.body); + const trigger = canvas.getByRole("combobox", { name: /toggle options/i }); + + await userEvent.click(trigger); + await userEvent.click(await page.findByText("John Doe")); + await userEvent.click(await page.findByText("Jane Doe")); + + const search = await page.findByPlaceholderText("Search"); + await userEvent.clear(search); + await userEvent.type(search, "smith"); + + await waitFor(() => { + expect(page.getByTestId("loading-indicator")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(page.getByText("John Smith")).toBeInTheDocument(); + }); + + expect(page.getByText("John Doe")).toBeInTheDocument(); + expect(page.getByText("Jane Doe")).toBeInTheDocument(); + expect(page.queryByText("Bob Johnson")).not.toBeInTheDocument(); + + await userEvent.clear(search); + await userEvent.type(search, "zzz"); + + await waitFor(() => { + expect(page.queryByText("John Smith")).not.toBeInTheDocument(); + }); + + expect(page.getByText("John Doe")).toBeInTheDocument(); + expect(page.getByText("Jane Doe")).toBeInTheDocument(); + + await userEvent.clear(search); + await userEvent.type(search, "smith"); + + await waitFor(() => { + expect(page.queryByTestId("loading-indicator")).not.toBeInTheDocument(); + }); + + expect(page.getByText("John Smith")).toBeInTheDocument(); + + // Picking an option must not wipe the query or the results behind it. + await userEvent.click(page.getByText("John Smith")); + + expect(search).toHaveValue("smith"); + expect(page.getByText("John Smith")).toBeInTheDocument(); + expect(page.queryByText("Bob Johnson")).not.toBeInTheDocument(); + expect(trigger).toHaveTextContent("John Smith"); + + // The search clear button drops the query only, never the selection. + await userEvent.click(page.getByRole("button", { name: "Clear" })); + + await waitFor(() => { + expect(search).toHaveValue(""); + }); + + await waitFor( + () => { + expect(page.getByText("Bob Johnson")).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + expect(trigger).toHaveTextContent("John Doe, Jane Doe, John Smith"); + + await userEvent.type(search, "smith"); + + await waitFor(() => { + expect(page.queryByTestId("loading-indicator")).not.toBeInTheDocument(); + }); + + // Dismissing with an active query still resets it, so a reopen starts fresh. + await userEvent.click(trigger); + await waitFor(() => { + expect(page.queryByPlaceholderText("Search")).not.toBeInTheDocument(); + }); + await userEvent.click(trigger); + + const reopenedSearch = await page.findByPlaceholderText("Search"); + expect(reopenedSearch).toHaveValue(""); + + await waitFor( + () => { + expect(page.getByText("Bob Johnson")).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + expect(trigger).toHaveTextContent("John Doe, Jane Doe, John Smith"); + }, +}; diff --git a/packages/frappe-ui-react/src/components/autoComplete/autoComplete.stories.tsx b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.stories.tsx index f6bd3a77..1c6b32ee 100644 --- a/packages/frappe-ui-react/src/components/autoComplete/autoComplete.stories.tsx +++ b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.stories.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import { useEffect, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import Autocomplete from "./autoComplete"; @@ -69,6 +69,19 @@ const meta: Meta = { control: "number", description: "Maximum number of options to display.", }, + keepSelectedVisible: { + control: "boolean", + description: + "Keep selected options visible in the list even if they don't match the search query.", + }, + searchValue: { + control: "text", + description: "Controlled search text shown in the popup input.", + }, + open: { + control: "boolean", + description: "Controlled open state for the popup.", + }, compareFn: { control: false, description: "Function to compare option values (for objects).", @@ -82,10 +95,50 @@ const meta: Meta = { control: "text", description: "CSS classes for the popover body.", }, + className: { + control: "text", + description: "Additional CSS classes for the outer wrapper.", + }, + labelClassName: { + control: "text", + description: "Additional CSS classes for the label.", + }, + triggerClassName: { + control: "text", + description: "Additional CSS classes for the trigger button.", + }, + searchInputClassName: { + control: "text", + description: "Additional CSS classes for the popup search input.", + }, + listClassName: { + control: "text", + description: "Additional CSS classes for the options list.", + }, + emptyMessage: { + control: "text", + description: "Custom empty-state message.", + }, + children: { + control: false, + description: "Optional custom trigger content rendered as the trigger.", + }, + renderFooter: { + control: false, + description: "Custom render function for the footer.", + }, onChange: { action: "update:value", description: "Event when selection changes.", }, + onOpenChange: { + action: "openChanged", + description: "Event when the popup opens or closes.", + }, + onSearchChange: { + action: "searchChanged", + description: "Event when the popup search text changes.", + }, prefix: { control: false, description: "Function to render a prefix element inside the input", @@ -151,19 +204,20 @@ export const SingleOptionWithPrefixSlots: Story = { value?.image && ( ) } itemPrefix={(value) => value?.image && ( - + ) } onChange={(_value) => { setValue(_value as string); }} options={options} + //@ts-expect-error -- this is fine since we have specified object type in docuementation compareFn={(a, b) => a?.value === b?.value} /> @@ -177,17 +231,18 @@ export const SingleOptionWithPrefixSlots: Story = { export const SingleOptionWithoutSearch: Story = { render: (args) => { - const [values, setValues] = useState([]); + const [value, setValue] = useState(null); return (
{ - setValues(_value as string[]); + setValue(_value as string | null); }} options={options} + //@ts-expect-error -- this is fine since we have specified object type in docuementation compareFn={(a, b) => a?.value === b?.value} />
@@ -195,14 +250,14 @@ export const SingleOptionWithoutSearch: Story = { }, args: { options: options, - label: "Select Persons", + label: "Select Person", showFooter: true, }, }; export const MultipleOptions: Story = { render: (args) => { - const [value, setValue] = useState(null); + const [value, setValue] = useState([]); return (
{ - setValue(_value as string); + setValue(_value as string[]); }} options={options} /> @@ -225,7 +280,7 @@ export const MultipleOptions: Story = { export const MultipleOptionsWithoutSearch: Story = { render: (args) => { - const [value, setValue] = useState(null); + const [value, setValue] = useState([]); return (
{ - setValue(_value as string); + setValue(_value as string[]); }} options={options} /> @@ -246,3 +301,61 @@ export const MultipleOptionsWithoutSearch: Story = { label: "Select Person", }, }; + +export const ControlledSearchLoading: Story = { + render: (args) => { + const [value, setValue] = useState([]); + const [searchValue, setSearchValue] = useState(""); + const [loading, setLoading] = useState(false); + const [filteredOptions, setFilteredOptions] = useState(options); + + useEffect(() => { + let cancelled = false; + + setLoading(true); + + const timeoutId = window.setTimeout(() => { + if (cancelled) { + return; + } + + setFilteredOptions( + searchValue.trim() + ? options.filter((option) => + option.label.toLowerCase().includes(searchValue.toLowerCase()) + ) + : options + ); + setLoading(false); + }, 250); + + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + }; + }, [searchValue]); + + return ( +
+ { + setValue(_value as string[]); + }} + /> +
+ ); + }, + args: { + label: "Select Person", + placeholder: "Start typing to search...", + options, + }, +}; diff --git a/packages/frappe-ui-react/src/components/autoComplete/autoComplete.tsx b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.tsx index 7e82552e..7f70727d 100644 --- a/packages/frappe-ui-react/src/components/autoComplete/autoComplete.tsx +++ b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.tsx @@ -1,33 +1,49 @@ +/** + * External dependencies. + */ import React, { - useState, - useRef, + startTransition, + useCallback, useEffect, + useId, useMemo, - useCallback, + useRef, + useState, } from "react"; -import { - Combobox, - ComboboxInput, - ComboboxOption, - ComboboxOptions, -} from "@headlessui/react"; +import { Combobox as BaseCombobox } from "@base-ui/react"; -import { Popover } from "../popover"; -import LoadingIndicator from "../loadingIndicator"; -import FeatherIcon from "../featherIcon"; +/** + * Internal dependencies. + */ import { Button } from "../button"; +import LoadingIndicator from "../loadingIndicator"; +import { Check, Close, SmallDown } from "../../icons"; +import { cn } from "../../utils"; import type { + AutocompleteChangeValue, AutocompleteOption, - AutocompleteOptionGroup, - AutocompleteOptions, AutocompleteProps, - Option, - OptionValue, } from "./types"; +import { + defaultCompareFn, + filterGroups, + flattenGroups, + getEmittedValue, + getOptionLabel, + getNextSelectedOptionCache, + hasAllSelectedOptions, + parsePlacement, + type InternalSelection, + type InternalOption, + type InternalOptionGroup, + processOptions, + resolveSelectedOptions, +} from "./utils"; const Autocomplete: React.FC = ({ value, options, + children, multiple = false, label, placeholder, @@ -39,188 +55,167 @@ const Autocomplete: React.FC = ({ itemPrefix, itemSuffix, maxOptions = 50, - compareFn = ( - a: NoInfer