+
{prefix && (
-
{prefix}
+
+ {prefix}
+
)}
{children || label?.toString()}
{suffix && (
-
{suffix}
+
+ {suffix}
+
)}
);
diff --git a/packages/frappe-ui-react/src/components/badge/tests/badge.tsx b/packages/frappe-ui-react/src/components/badge/tests/badge.tsx
index ca6f2552..76893546 100644
--- a/packages/frappe-ui-react/src/components/badge/tests/badge.tsx
+++ b/packages/frappe-ui-react/src/components/badge/tests/badge.tsx
@@ -17,13 +17,13 @@ describe("Badge", () => {
test("renders with different themes", () => {
const { rerender } = render(
Red Badge );
expect(screen.getByText("Red Badge")).toHaveClass("text-ink-red-4");
- expect(screen.getByText("Red Badge")).toHaveClass("bg-surface-red-1");
+ expect(screen.getByText("Red Badge")).toHaveClass("bg-surface-red-2");
rerender(
Blue Badge );
expect(screen.getByText("Blue Badge")).toHaveClass("text-ink-blue-2");
rerender(
Green Badge );
- expect(screen.getByText("Green Badge")).toHaveClass("text-ink-green-3");
+ expect(screen.getByText("Green Badge")).toHaveClass("text-ink-green-4");
});
test("renders with different sizes", () => {
diff --git a/packages/frappe-ui-react/src/components/badge/types.ts b/packages/frappe-ui-react/src/components/badge/types.ts
index f0e63bdf..b25978ff 100644
--- a/packages/frappe-ui-react/src/components/badge/types.ts
+++ b/packages/frappe-ui-react/src/components/badge/types.ts
@@ -8,4 +8,5 @@ export interface BadgeProps {
prefix?: ReactNode;
suffix?: ReactNode;
children?: ReactNode;
+ className?: string;
}
diff --git a/packages/frappe-ui-react/src/components/badge/variants.ts b/packages/frappe-ui-react/src/components/badge/variants.ts
new file mode 100644
index 00000000..86e981a1
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/badge/variants.ts
@@ -0,0 +1,77 @@
+import { cva } from "class-variance-authority";
+
+export const badgeVariants = cva(
+ "inline-flex select-none items-center gap-1 rounded-full",
+ {
+ variants: {
+ theme: {
+ gray: "text-ink-gray-6",
+ blue: "text-ink-blue-2",
+ green: "text-ink-green-4",
+ orange: "text-ink-amber-4",
+ red: "text-ink-red-4",
+ },
+ variant: {
+ subtle: "",
+ solid: "",
+ outline: "bg-transparent border",
+ ghost: "bg-transparent",
+ },
+ size: {
+ sm: "h-4 text-xs px-1.5",
+ md: "h-5 text-xs px-1.5",
+ lg: "h-6 text-sm px-2",
+ },
+ },
+ compoundVariants: [
+ { variant: "subtle", theme: "gray", className: "bg-surface-gray-2" },
+ { variant: "subtle", theme: "blue", className: "bg-surface-blue-1" },
+ { variant: "subtle", theme: "green", className: "bg-surface-green-2" },
+ { variant: "subtle", theme: "orange", className: "bg-surface-amber-2" },
+ { variant: "subtle", theme: "red", className: "bg-surface-red-2" },
+ {
+ variant: "solid",
+ theme: "gray",
+ className: "text-ink-white bg-surface-gray-7",
+ },
+ {
+ variant: "solid",
+ theme: "blue",
+ className: "text-ink-blue-1 bg-surface-blue-2",
+ },
+ {
+ variant: "solid",
+ theme: "green",
+ className: "text-ink-green-1 bg-surface-green-3",
+ },
+ {
+ variant: "solid",
+ theme: "orange",
+ className: "text-ink-amber-1 bg-surface-amber-2",
+ },
+ {
+ variant: "solid",
+ theme: "red",
+ className: "text-ink-red-1 bg-surface-red-4",
+ },
+ { variant: "outline", theme: "gray", className: "border-outline-gray-1" },
+ { variant: "outline", theme: "blue", className: "border-outline-blue-1" },
+ {
+ variant: "outline",
+ theme: "green",
+ className: "border-outline-green-2",
+ },
+ {
+ variant: "outline",
+ theme: "orange",
+ className: "border-outline-amber-2",
+ },
+ { variant: "outline", theme: "red", className: "border-outline-red-2" },
+ ],
+ defaultVariants: {
+ theme: "gray",
+ size: "md",
+ variant: "subtle",
+ },
+ }
+);
diff --git a/packages/frappe-ui-react/src/components/breadcrumbs/breadcrumbs.stories.tsx b/packages/frappe-ui-react/src/components/breadcrumbs/breadcrumbs.stories.tsx
index f246cbe1..d8621fed 100644
--- a/packages/frappe-ui-react/src/components/breadcrumbs/breadcrumbs.stories.tsx
+++ b/packages/frappe-ui-react/src/components/breadcrumbs/breadcrumbs.stories.tsx
@@ -1,7 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ChevronDown, Clock } from "lucide-react";
+import { action } from "storybook/actions";
+
import Breadcrumbs from "./breadcrumbs";
import { type BreadcrumbsProps } from "./types";
-import { action } from "storybook/actions";
export default {
title: "Components/Breadcrumbs",
@@ -12,6 +14,22 @@ export default {
description:
"An array of breadcrumb items, each with a label, and optional onClick.",
},
+ size: {
+ control: { type: "select", options: ["sm", "md"] },
+ description: "Size of the breadcrumb items.",
+ },
+ highlightLastItem: {
+ control: "boolean",
+ description: "Whether to highlight the last breadcrumb item.",
+ },
+ highlightAllItems: {
+ control: "boolean",
+ description: "Whether to highlight all breadcrumb items.",
+ },
+ compactCrumbs: {
+ control: "boolean",
+ description: "Whether to compact the breadcrumbs on smaller screens.",
+ },
renderPrefix: {
description:
"Function to render a prefix element for each breadcrumb item.",
@@ -20,6 +38,18 @@ export default {
description:
"Function to render a suffix element for each breadcrumb item.",
},
+ className: {
+ control: "text",
+ description: "Additional class name for the breadcrumbs container.",
+ },
+ crumbClassName: {
+ control: "text",
+ description: "Additional class name for each breadcrumb item.",
+ },
+ separatorClassName: {
+ control: "text",
+ description: "Additional class name for the separator element.",
+ },
},
parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
tags: ["autodocs"],
@@ -34,14 +64,17 @@ export const WithOnClickOption: StoryObj
= {
args: {
items: [
{
+ id: "home",
label: "Home",
onClick: action("Home clicked"),
},
{
+ id: "views",
label: "Views",
onClick: action("Views clicked"),
},
{
+ id: "kanban",
label: "Kanban",
onClick: action("Kanban clicked"),
},
@@ -54,17 +87,79 @@ export const WithPrefixSlot: StoryObj = {
args: {
items: [
{
+ id: "home",
label: "Home",
prefixIcon: "🏡",
},
{
+ id: "views",
label: "Views",
prefixIcon: "🏞️",
},
{
+ id: "list",
label: "List",
prefixIcon: "📃",
},
],
},
};
+
+export const WithDropdown: StoryObj = {
+ ...BreadcrumbsTemplate,
+ args: {
+ items: [
+ {
+ id: "timesheets",
+ label: "Timesheets",
+ },
+ {
+ id: "personal",
+ label: "Personal",
+ prefixIcon: ,
+ suffixIcon: ,
+ dropdown: {
+ dropdownClassName: "w-[220px] px-1",
+ groupClassName: "px-0 py-1 space-y-1",
+ itemClassName: "text-ink-gray-8 hover:text-ink-gray-7",
+ selectedKey: "personal",
+ selectedGroupKey: "views-group",
+ options: [
+ {
+ group: "",
+ key: "views-group",
+ items: [
+ {
+ label: "Personal",
+ key: "personal",
+ icon: "clock",
+ },
+ {
+ label: "Team",
+ key: "team",
+ icon: "copy",
+ },
+ {
+ label: "Project",
+ key: "project",
+ icon: "briefcase",
+ },
+ ],
+ },
+ {
+ group: "",
+ key: "create-group",
+ items: [
+ {
+ label: "Create View",
+ key: "create-view",
+ icon: "plus",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/breadcrumbs/breadcrumbs.tsx b/packages/frappe-ui-react/src/components/breadcrumbs/breadcrumbs.tsx
index 8dd9b318..e830d3ef 100644
--- a/packages/frappe-ui-react/src/components/breadcrumbs/breadcrumbs.tsx
+++ b/packages/frappe-ui-react/src/components/breadcrumbs/breadcrumbs.tsx
@@ -1,33 +1,47 @@
-import React, { useCallback, useMemo } from "react";
+/**
+ * External dependencies.
+ */
+import React, { useMemo } from "react";
+import { useRender } from "@base-ui/react/use-render";
+
+/**
+ * Internal dependencies.
+ */
import useWindowSize from "../hooks/useWindowSize";
-import {
- Dropdown,
- type DropdownOption,
- type DropdownOptions,
-} from "../dropdown";
+import { Dropdown, type DropdownOption, type DropdownOptions } from "../dropdown";
import { Button } from "../button";
-import type { BreadcrumbsProps, BreadcrumbItem } from "./types";
+import type { BreadcrumbsProps, BreadcrumbItem, BreadcrumbDropdownState } from "./types";
+import { cn } from "../../utils";
+import { crumbVariants, separatorVariants } from "./variants";
+import { ThreeDotsIcon } from "./threeDotsIcon";
+
+const dropdownStateAttributesMapping = {
+ item: () => null,
+};
-const ThreeDotsIcon: React.FC = () => (
-
-
-
-
-
-);
+const BreadcrumbDropdown = ({ item, children }: { item: BreadcrumbItem; children: React.ReactNode }) => {
+ const state: BreadcrumbDropdownState = useMemo(() => ({ item }), [item]);
+ const { renderDropdown, ...dropdownProps } = item.dropdown ?? { options: [] };
+ return useRender({
+ render: renderDropdown ?? ,
+ state,
+ stateAttributesMapping: dropdownStateAttributesMapping,
+ props: { children },
+ });
+};
-const Breadcrumbs: React.FC = ({ items }) => {
+const Breadcrumbs: React.FC = ({
+ items,
+ size = "lg",
+ highlightLastItem = true,
+ highlightAllItems = false,
+ compactCrumbs = true,
+ className,
+ crumbClassName,
+ separatorClassName,
+ renderPrefix,
+ renderSuffix,
+}) => {
const { width } = useWindowSize();
const filteredItems = useMemo(() => {
@@ -35,7 +49,7 @@ const Breadcrumbs: React.FC = ({ items }) => {
}, [items]);
const dropdownItems: DropdownOption[] = useMemo(() => {
- if (width > 640) return [];
+ if (width > 640 || !compactCrumbs) return [];
const allExceptLastTwo = filteredItems.slice(0, -2);
return allExceptLastTwo.map((item) => {
@@ -49,26 +63,12 @@ const Breadcrumbs: React.FC = ({ items }) => {
onClick: onClick,
};
});
- }, [width, filteredItems]);
+ }, [width, filteredItems, compactCrumbs]);
const crumbs: BreadcrumbItem[] = useMemo(() => {
- if (width > 640) return filteredItems;
+ if (width > 640 || !compactCrumbs) return filteredItems;
return filteredItems.slice(-2);
- }, [width, filteredItems]);
-
- const renderSuffix = useCallback((item: BreadcrumbItem) => {
- if (!item.suffixIcon) {
- return null;
- }
- return {item.suffixIcon} ;
- }, []);
-
- const renderPrefix = useCallback((item: BreadcrumbItem) => {
- if (!item.prefixIcon) {
- return null;
- }
- return {item.prefixIcon} ;
- }, []);
+ }, [width, filteredItems, compactCrumbs]);
return (
@@ -80,7 +80,11 @@ const Breadcrumbs: React.FC = ({ items }) => {
/
@@ -88,39 +92,59 @@ const Breadcrumbs: React.FC = ({ items }) => {
)}
-
+
{crumbs.map((item, i) => {
const isLast = i === crumbs.length - 1;
- const commonClasses = `flex items-center rounded px-0.5 py-1 text-lg font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3 ${
- isLast ? "text-ink-gray-9" : "text-ink-gray-5 hover:text-ink-gray-7"
- }`;
- const handleClick = (
- e: React.MouseEvent
- ) => {
- if (item.onClick) {
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (item.onClick && item?.interactive !== false) {
item.onClick();
}
-
- if (item.onClick) {
- e.preventDefault();
- }
};
+ const crumbContent = (
+
+ {renderPrefix ? (
+ renderPrefix(item)
+ ) : item?.prefixIcon ? (
+ {item.prefixIcon}
+ ) : null}
+ {item.label}
+ {renderSuffix ? (
+ renderSuffix(item)
+ ) : item?.suffixIcon ? (
+ {item.suffixIcon}
+ ) : null}
+
+ );
+
return (
-
-
- {renderPrefix(item)}
- {item.label}
- {renderSuffix(item)}
-
+
+ {item.dropdown ? {crumbContent} : crumbContent}
{!isLast && (
/
diff --git a/packages/frappe-ui-react/src/components/breadcrumbs/threeDotsIcon.tsx b/packages/frappe-ui-react/src/components/breadcrumbs/threeDotsIcon.tsx
new file mode 100644
index 00000000..676d22b3
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/breadcrumbs/threeDotsIcon.tsx
@@ -0,0 +1,18 @@
+export const ThreeDotsIcon: React.FC = () => (
+
+
+
+
+
+);
diff --git a/packages/frappe-ui-react/src/components/breadcrumbs/types.ts b/packages/frappe-ui-react/src/components/breadcrumbs/types.ts
index a64143b7..ffb6bc63 100644
--- a/packages/frappe-ui-react/src/components/breadcrumbs/types.ts
+++ b/packages/frappe-ui-react/src/components/breadcrumbs/types.ts
@@ -1,14 +1,42 @@
import type { ReactNode } from "react";
+import type { useRender } from "@base-ui/react/use-render";
+import type { DropdownProps } from "../dropdown";
export interface BreadcrumbItem {
+ id?: string | number;
label: string;
onClick?: () => void;
prefixIcon?: ReactNode;
suffixIcon?: ReactNode;
+ dropdown?: BreadcrumbDropdownProps;
+ interactive?: boolean;
}
+export type BreadcrumbDropdownState = {
+ item: BreadcrumbItem;
+};
+
+export type BreadcrumbRenderDropdown =
+ useRender.RenderProp;
+
+export type BreadcrumbDropdownProps = DropdownProps & {
+ /**
+ * Replace the default `` wrapper of this crumb. Accepts a React
+ * element or a render function that receives the merged props (`children`
+ * is the crumb trigger) and the crumb's `state`.
+ */
+ renderDropdown?: BreadcrumbRenderDropdown;
+};
+
export interface BreadcrumbsProps {
items: BreadcrumbItem[];
+ size?: "sm" | "md" | "lg";
+ highlightLastItem?: boolean;
+ highlightAllItems?: boolean;
+ compactCrumbs?: boolean;
+ className?: string;
+ crumbClassName?: string;
+ separatorClassName?: string;
renderPrefix?: (item: BreadcrumbItem) => ReactNode;
renderSuffix?: (item: BreadcrumbItem) => ReactNode;
}
diff --git a/packages/frappe-ui-react/src/components/breadcrumbs/variants.ts b/packages/frappe-ui-react/src/components/breadcrumbs/variants.ts
new file mode 100644
index 00000000..51fe497e
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/breadcrumbs/variants.ts
@@ -0,0 +1,54 @@
+import { cva } from "class-variance-authority";
+
+export const crumbVariants = cva(
+ "flex items-center rounded px-1.25 py-1 gap-1 font-medium focus-visible:outline-2 focus-visible:outline-default",
+ {
+ variants: {
+ size: {
+ sm: "text-sm",
+ md: "text-base",
+ lg: "text-lg",
+ },
+ highlightItem: {
+ true: "text-ink-gray-9",
+ false: "text-ink-gray-5",
+ },
+ interactive: {
+ true: "cursor-pointer",
+ false: "cursor-default!",
+ },
+ },
+ compoundVariants: [
+ {
+ highlightItem: false,
+ interactive: true,
+ className: "hover:text-ink-gray-9",
+ },
+ ],
+ defaultVariants: {
+ size: "lg",
+ highlightItem: false,
+ interactive: true,
+ },
+ }
+);
+
+export const separatorVariants = cva(
+ "text-ink-gray-4 font-medium select-none",
+ {
+ variants: {
+ size: {
+ sm: "text-sm",
+ md: "text-base",
+ lg: "text-lg",
+ },
+ highlightItem: {
+ true: "text-ink-gray-9",
+ false: "text-ink-gray-4",
+ },
+ },
+ defaultVariants: {
+ size: "lg",
+ },
+ }
+);
diff --git a/packages/frappe-ui-react/src/components/button/button.tsx b/packages/frappe-ui-react/src/components/button/button.tsx
index 59c66c8f..046fb3dc 100644
--- a/packages/frappe-ui-react/src/components/button/button.tsx
+++ b/packages/frappe-ui-react/src/components/button/button.tsx
@@ -1,8 +1,15 @@
+/**
+ * External dependencies.
+ */
import React from "react";
+/**
+ * Internal dependencies.
+ */
import FeatherIcon, { type FeatherIconProps } from "../featherIcon";
import LoadingIndicator from "../loadingIndicator";
import type { ButtonProps, ButtonThemeVariant } from "./types";
+import { cn } from "../../utils";
const Button = ({
theme = "gray",
@@ -58,10 +65,10 @@ const Button = ({
}[theme];
const focusClasses = {
- gray: "focus-visible:ring focus-visible:ring-outline-gray-3",
- blue: "focus-visible:ring focus-visible:ring-blue-400",
- green: "focus-visible:ring focus-visible:ring-outline-green-2",
- red: "focus-visible:ring focus-visible:ring-outline-red-2",
+ gray: "focus-visible:outline-2 focus-visible:outline-default",
+ blue: "focus-visible:outline-2 focus-visible:outline-blue",
+ green: "focus-visible:outline-2 focus-visible:outline-green",
+ red: "focus-visible:outline-2 focus-visible:outline-red",
}[theme];
const variantClasses = {
@@ -132,15 +139,13 @@ const Button = ({
"2xl": "h-4.5 w-4.5",
}[size];
- const buttonClasses = [
- "inline-flex items-center justify-center gap-2 transition-colors focus:outline-none",
+ const buttonClasses = cn(
+ "inline-flex items-center justify-center gap-2 transition-colors",
isDisabled ? disabledClasses : variantClasses,
focusClasses,
sizeClasses,
- className,
- ]
- .filter(Boolean)
- .join(" ");
+ className
+ );
const handleClick = (e: React.MouseEvent) => {
if (link) {
@@ -191,7 +196,9 @@ const Button = ({
) : isIconButton && !loading ? (
renderIcon(icon, label)
) : (
-
+
{children || label}
)}
diff --git a/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx b/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx
index 8912aa46..3aae3a97 100644
--- a/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx
+++ b/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import type { CalendarConfig, CalendarEvent } from "./types";
import { Calendar } from "./calendar";
import { Button } from "../button";
-import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
import { Select } from "../select";
import { DatePicker } from "../datePicker";
import { dayjs } from "../../utils/dayjs";
@@ -43,6 +43,7 @@ const config: Partial = {
enableShortcuts: false,
timeFormat: "12h",
};
+
const getCurrentMonthYear = () => {
const date = new Date();
const year = date.getFullYear();
@@ -147,6 +148,7 @@ export const CustomHeader: Story = {
args: {
...Default.args,
header: ({
+ activeView,
currentMonthYear,
decrement,
increment,
@@ -155,7 +157,7 @@ export const CustomHeader: Story = {
setCalendarDate,
formatter,
}) => (
-
+
- {({ togglePopover, displayValue }) => (
+ {({ displayValue }) => (
{displayValue}
@@ -191,13 +192,13 @@ export const CustomHeader: Story = {
/>
updateActiveView(e.target.value)}
+ className="w-auto"
+ value={activeView}
+ onChange={(event) => updateActiveView(event.target.value)}
options={enabledModes.map((mode: { id: string; label: string }) => ({
value: mode.id,
label: mode.label,
}))}
- variant="ghost"
- prefix={() => }
/>
),
diff --git a/packages/frappe-ui-react/src/components/calendar/calendar.tsx b/packages/frappe-ui-react/src/components/calendar/calendar.tsx
index fe508a9f..56288734 100644
--- a/packages/frappe-ui-react/src/components/calendar/calendar.tsx
+++ b/packages/frappe-ui-react/src/components/calendar/calendar.tsx
@@ -89,11 +89,10 @@ export const Calendar = ({
}
clearable={false}
>
- {({ togglePopover, displayValue }) => (
+ {({ displayValue }) => (
{displayValue}
diff --git a/packages/frappe-ui-react/src/components/charts/chart.stories.tsx b/packages/frappe-ui-react/src/components/charts/chart.stories.tsx
index 236472aa..8f31568d 100644
--- a/packages/frappe-ui-react/src/components/charts/chart.stories.tsx
+++ b/packages/frappe-ui-react/src/components/charts/chart.stories.tsx
@@ -1,4 +1,3 @@
-import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import AxisChart, { type AxisChartProps } from "./axisChart";
diff --git a/packages/frappe-ui-react/src/components/charts/charts.tsx b/packages/frappe-ui-react/src/components/charts/charts.tsx
index fed3aacf..eba7af4d 100644
--- a/packages/frappe-ui-react/src/components/charts/charts.tsx
+++ b/packages/frappe-ui-react/src/components/charts/charts.tsx
@@ -1,6 +1,6 @@
import React, { useRef, useEffect } from "react";
import { init, type ECharts } from "echarts";
-import "echarts/theme/rainbow";
+import "echarts/theme/rainbow.js";
import { debounce } from "../../utils/debounce";
import type { ChartsWrapperProps } from "./types";
diff --git a/packages/frappe-ui-react/src/components/checkbox/checkbox.stories.tsx b/packages/frappe-ui-react/src/components/checkbox/checkbox.stories.tsx
index 0635c1e8..aaa40d48 100644
--- a/packages/frappe-ui-react/src/components/checkbox/checkbox.stories.tsx
+++ b/packages/frappe-ui-react/src/components/checkbox/checkbox.stories.tsx
@@ -43,6 +43,18 @@ export default {
control: "text",
description: "The HTML id attribute for the checkbox input",
},
+ extraClasses: {
+ control: "text",
+ description: "Additional classes applied to the checkbox input.",
+ },
+ extraLabelClasses: {
+ control: "text",
+ description: "Additional classes applied to the label.",
+ },
+ extraWrapperClasses: {
+ control: "text",
+ description: "Additional classes applied to the wrapper element.",
+ },
},
tags: ["autodocs"],
} as Meta;
diff --git a/packages/frappe-ui-react/src/components/checkbox/checkbox.tsx b/packages/frappe-ui-react/src/components/checkbox/checkbox.tsx
index 529f88ee..1d38eb72 100644
--- a/packages/frappe-ui-react/src/components/checkbox/checkbox.tsx
+++ b/packages/frappe-ui-react/src/components/checkbox/checkbox.tsx
@@ -1,6 +1,7 @@
import React, { useMemo, useCallback } from "react";
import type { CheckboxProps } from "./types";
import type { SizeTypes } from "../../common/types";
+import { cn } from "../../utils";
const Checkbox: React.FC = ({
size = "sm",
@@ -11,6 +12,8 @@ const Checkbox: React.FC = ({
onChange,
htmlId,
extraClasses = "",
+ extraLabelClasses = "",
+ extraWrapperClasses = "",
}) => {
const labelClasses = useMemo(() => {
const sizeClasses: string = {
@@ -30,16 +33,16 @@ const Checkbox: React.FC = ({
const interactionClasses = disabled
? ""
: padding
- ? "focus:ring-0"
- : "hover:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 active:bg-surface-gray-2";
+ ? ""
+ : "hover:shadow-sm active:bg-surface-gray-2";
const sizeClasses: string = {
sm: "w-3.5 h-3.5",
md: "w-4 h-4",
}[size as keyof SizeTypes];
- return `rounded-sm mt-[1px] ${extraClasses} ${baseClasses} ${interactionClasses} ${sizeClasses}`;
- }, [disabled, padding, size, extraClasses]);
+ return `rounded-sm mt-[1px] ${baseClasses} ${interactionClasses} ${sizeClasses}`;
+ }, [disabled, padding, size]);
const wrapperClasses = useMemo(() => {
let paddingClasses = "";
@@ -53,7 +56,7 @@ const Checkbox: React.FC = ({
const interactionClasses =
padding && !disabled
- ? "focus-within:bg-surface-gray-2 focus-within:ring-2 focus-within:ring-outline-gray-3 hover:bg-surface-gray-3 active:bg-surface-gray-4"
+ ? "focus-within:bg-surface-gray-2 focus-within:outline-2 focus-within:outline-default hover:bg-surface-gray-3 active:bg-surface-gray-4"
: "";
return `inline-flex space-x-2 rounded transition ${paddingClasses} ${interactionClasses}`;
@@ -67,9 +70,9 @@ const Checkbox: React.FC = ({
);
return (
-
+
= ({
data-testid="checkbox"
/>
{label && (
-
+
{label}
)}
diff --git a/packages/frappe-ui-react/src/components/checkbox/types.ts b/packages/frappe-ui-react/src/components/checkbox/types.ts
index 7f839434..fc20cde5 100644
--- a/packages/frappe-ui-react/src/components/checkbox/types.ts
+++ b/packages/frappe-ui-react/src/components/checkbox/types.ts
@@ -8,4 +8,6 @@ export interface CheckboxProps {
onChange?: (val: boolean) => void;
htmlId?: string;
extraClasses?: string;
+ extraLabelClasses?: string;
+ extraWrapperClasses?: string;
}
diff --git a/packages/frappe-ui-react/src/components/columnSelector/columnSelector.interactions.stories.tsx b/packages/frappe-ui-react/src/components/columnSelector/columnSelector.interactions.stories.tsx
new file mode 100644
index 00000000..f9651d4a
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/columnSelector/columnSelector.interactions.stories.tsx
@@ -0,0 +1,353 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
+
+import ColumnSelector from "./columnSelector";
+import type { ColumnSelectorProps, SelectorColumn } from "./types";
+
+const ticketColumns: SelectorColumn[] = [
+ { label: "ID", value: "name" },
+ { label: "Subject", value: "subject" },
+ { label: "Status", value: "status" },
+ { label: "Priority", value: "priority" },
+ { label: "Customer", value: "customer" },
+];
+
+const ticketFields: SelectorColumn[] = [
+ ...ticketColumns,
+ { label: "Description", value: "description" },
+ { label: "Team", value: "agent_group" },
+];
+
+function StatefulColumnSelector(props: ColumnSelectorProps) {
+ const [columns, setColumns] = useState(props.columns);
+
+ return (
+ {
+ setColumns(next);
+ props.onColumnsChange(next);
+ }}
+ onReset={
+ props.onReset &&
+ (() => {
+ setColumns(props.columns);
+ props.onReset?.();
+ })
+ }
+ />
+ );
+}
+
+const meta: Meta = {
+ title: "Components/ColumnSelector/Interactions",
+ component: ColumnSelector,
+ parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
+ args: {
+ columns: ticketColumns,
+ availableColumns: ticketFields,
+ onColumnsChange: fn(),
+ },
+ render: (args) => ,
+};
+
+export default meta;
+type Story = StoryObj;
+
+const values = (columns: SelectorColumn[]) => columns.map((c) => c.value);
+
+const openSelector = async (canvas: ReturnType) => {
+ await userEvent.click(canvas.getByRole("button", { name: "Columns" }));
+ return screen.findByRole("dialog");
+};
+
+const addColumnTrigger = () =>
+ screen.getByRole("combobox", { name: /toggle options/i });
+
+const listedLabels = () =>
+ screen
+ .getAllByRole("listitem")
+ .map((item) => item.querySelector(".truncate")?.textContent);
+
+export const RemovesColumn: Story = {
+ play: async ({ canvas, args }) => {
+ await openSelector(canvas);
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "Remove column: Status" })
+ );
+
+ await waitFor(() =>
+ expect(listedLabels()).toEqual(["ID", "Subject", "Priority", "Customer"])
+ );
+ await expect(args.onColumnsChange).toHaveBeenCalledTimes(1);
+ await expect(
+ values(
+ (args.onColumnsChange as ReturnType).mock
+ .lastCall?.[0] as SelectorColumn[]
+ )
+ ).toEqual(["name", "subject", "priority", "customer"]);
+ },
+};
+
+export const AddsUnusedColumn: Story = {
+ play: async ({ canvas, args }) => {
+ await openSelector(canvas);
+
+ await userEvent.click(addColumnTrigger());
+
+ const options = await screen.findAllByRole("option");
+ await expect(options.map((o) => o.textContent?.trim())).toEqual([
+ "Description",
+ "Team",
+ ]);
+
+ await userEvent.click(screen.getByRole("option", { name: "Team" }));
+
+ await waitFor(() =>
+ expect(listedLabels()).toEqual([
+ "ID",
+ "Subject",
+ "Status",
+ "Priority",
+ "Customer",
+ "Team",
+ ])
+ );
+ await expect(args.onColumnsChange).toHaveBeenCalledTimes(1);
+ },
+};
+
+export const HidesRemoveAtMinimum: Story = {
+ args: { columns: [ticketColumns[0]] },
+ play: async ({ canvas }) => {
+ await openSelector(canvas);
+
+ await expect(listedLabels()).toEqual(["ID"]);
+ await expect(
+ screen.queryByRole("button", { name: /^Remove column/ })
+ ).not.toBeInTheDocument();
+ await expect(addColumnTrigger()).toHaveTextContent("Add Column");
+ },
+};
+
+export const HidesAddColumnWhenEveryColumnIsAdded: Story = {
+ args: { columns: ticketFields },
+ play: async ({ canvas }) => {
+ await openSelector(canvas);
+
+ await expect(
+ screen.queryByRole("combobox", { name: /toggle options/i })
+ ).not.toBeInTheDocument();
+ await expect(screen.queryByText("Add Column")).not.toBeInTheDocument();
+ },
+};
+
+export const ResetFiresCallbackOnly: Story = {
+ args: { onReset: fn() },
+ play: async ({ canvas, args }) => {
+ await openSelector(canvas);
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "Remove column: Customer" })
+ );
+ await waitFor(() => expect(listedLabels()).toHaveLength(4));
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "Reset to Default" })
+ );
+
+ await expect(args.onReset).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(listedLabels()).toHaveLength(5));
+ await expect(args.onColumnsChange).toHaveBeenCalledTimes(1);
+ },
+};
+
+export const OmitsResetWithoutHandler: Story = {
+ play: async ({ canvas }) => {
+ await openSelector(canvas);
+
+ await expect(
+ screen.queryByRole("button", { name: "Reset to Default" })
+ ).not.toBeInTheDocument();
+ },
+};
+
+export const ReordersWithKeyboard: Story = {
+ play: async ({ canvas, args }) => {
+ await openSelector(canvas);
+
+ screen.getByRole("button", { name: "Reorder column: Subject" }).focus();
+ await userEvent.keyboard("[Space]");
+ await userEvent.keyboard("[ArrowDown]");
+ await userEvent.keyboard("[Space]");
+
+ await waitFor(() =>
+ expect(listedLabels()).toEqual([
+ "ID",
+ "Status",
+ "Subject",
+ "Priority",
+ "Customer",
+ ])
+ );
+ await expect(args.onColumnsChange).toHaveBeenCalledTimes(1);
+ },
+};
+
+export const HidesDragHandlesWhenNotReorderable: Story = {
+ args: { reorderable: false },
+ play: async ({ canvas }) => {
+ await openSelector(canvas);
+
+ await expect(
+ screen.queryByRole("button", { name: /^Reorder column/ })
+ ).not.toBeInTheDocument();
+ },
+};
+
+export const PinsAndUnpinsColumn: Story = {
+ args: { pinnable: true },
+ play: async ({ canvas, args }) => {
+ await openSelector(canvas);
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "Pin column: Status" })
+ );
+
+ await waitFor(() =>
+ expect(listedLabels()).toEqual([
+ "Status",
+ "ID",
+ "Subject",
+ "Priority",
+ "Customer",
+ ])
+ );
+ const unpin = screen.getByRole("button", { name: "Unpin column: Status" });
+ await expect(unpin).toHaveAttribute("aria-pressed", "true");
+ await expect(
+ (
+ (args.onColumnsChange as ReturnType).mock
+ .lastCall?.[0] as SelectorColumn[]
+ )[0]
+ ).toMatchObject({ value: "status", pinned: true });
+
+ await userEvent.click(unpin);
+
+ await waitFor(() =>
+ expect(listedLabels()).toEqual([
+ "Status",
+ "ID",
+ "Subject",
+ "Priority",
+ "Customer",
+ ])
+ );
+ await expect(
+ screen.getByRole("button", { name: "Pin column: Status" })
+ ).toHaveAttribute("aria-pressed", "false");
+ await expect(
+ screen.queryByRole("button", { name: /^Unpin column/ })
+ ).not.toBeInTheDocument();
+ },
+};
+
+export const ListsPinnedColumnsFirst: Story = {
+ args: {
+ pinnable: true,
+ columns: ticketColumns.map((column) => ({
+ ...column,
+ pinned: column.value === "priority",
+ })),
+ },
+ play: async ({ canvas }) => {
+ await openSelector(canvas);
+
+ await expect(listedLabels()).toEqual([
+ "Priority",
+ "ID",
+ "Subject",
+ "Status",
+ "Customer",
+ ]);
+ await expect(
+ screen.getByRole("button", { name: "Unpin column: Priority" })
+ ).toHaveAttribute("aria-pressed", "true");
+ },
+};
+
+export const KeepsKeyboardReorderWithinGroup: Story = {
+ args: {
+ pinnable: true,
+ columns: ticketColumns.map((column) => ({
+ ...column,
+ pinned: column.value === "name",
+ })),
+ },
+ play: async ({ canvas, args }) => {
+ await openSelector(canvas);
+
+ screen.getByRole("button", { name: "Reorder column: Subject" }).focus();
+ await userEvent.keyboard("[Space]");
+ await userEvent.keyboard("[ArrowUp]");
+ await userEvent.keyboard("[Space]");
+
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ await expect(listedLabels()).toEqual([
+ "ID",
+ "Subject",
+ "Status",
+ "Priority",
+ "Customer",
+ ]);
+ await expect(args.onColumnsChange).not.toHaveBeenCalled();
+ },
+};
+
+export const DisabledTriggerStaysClosed: Story = {
+ args: { disabled: true },
+ play: async ({ canvas }) => {
+ const trigger = canvas.getByRole("button", { name: "Columns" });
+ await expect(trigger).toBeDisabled();
+
+ await userEvent.click(trigger, { pointerEventsCheck: 0 });
+
+ await expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ },
+};
+
+export const UsesTranslatedLabels: Story = {
+ args: {
+ pinnable: true,
+ onReset: fn(),
+ labels: {
+ trigger: "Colonnes",
+ addColumn: "Ajouter une colonne",
+ resetToDefault: "Réinitialiser",
+ remove: "Supprimer la colonne",
+ reorder: "Réordonner la colonne",
+ pin: "Épingler la colonne",
+ },
+ },
+ play: async ({ canvas }) => {
+ await userEvent.click(canvas.getByRole("button", { name: "Colonnes" }));
+ await screen.findByRole("dialog");
+
+ await expect(addColumnTrigger()).toHaveTextContent("Ajouter une colonne");
+ await expect(
+ screen.getByRole("button", { name: "Réinitialiser" })
+ ).toBeInTheDocument();
+ await expect(
+ screen.getByRole("button", { name: "Supprimer la colonne: ID" })
+ ).toBeInTheDocument();
+ await expect(
+ screen.getByRole("button", { name: "Réordonner la colonne: ID" })
+ ).toBeInTheDocument();
+ await expect(
+ screen.getByRole("button", { name: "Épingler la colonne: ID" })
+ ).toBeInTheDocument();
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/columnSelector/columnSelector.stories.tsx b/packages/frappe-ui-react/src/components/columnSelector/columnSelector.stories.tsx
new file mode 100644
index 00000000..35cb31d1
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/columnSelector/columnSelector.stories.tsx
@@ -0,0 +1,132 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import ColumnSelector from "./columnSelector";
+import type { ColumnSelectorProps, SelectorColumn } from "./types";
+
+const ticketColumns: SelectorColumn[] = [
+ { label: "ID", value: "name" },
+ { label: "Subject", value: "subject" },
+ { label: "Status", value: "status" },
+ { label: "Priority", value: "priority" },
+ { label: "Customer", value: "customer" },
+];
+
+const ticketFields: SelectorColumn[] = [
+ ...ticketColumns,
+ { label: "Description", value: "description" },
+ { label: "Team", value: "agent_group" },
+ { label: "Type", value: "ticket_type" },
+ { label: "Total Hold Time", value: "total_hold_time" },
+];
+
+function StatefulColumnSelector(props: ColumnSelectorProps) {
+ const [columns, setColumns] = useState(props.columns);
+
+ return (
+ {
+ setColumns(next);
+ props.onColumnsChange(next);
+ }}
+ onReset={
+ props.onReset &&
+ (() => {
+ setColumns(props.columns);
+ props.onReset?.();
+ })
+ }
+ />
+ );
+}
+
+const meta: Meta = {
+ title: "Components/ColumnSelector",
+ component: ColumnSelector,
+ tags: ["autodocs"],
+ parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
+ argTypes: {
+ minColumns: { control: "number" },
+ reorderable: { control: "boolean" },
+ pinnable: { control: "boolean" },
+ maxPinned: { control: "number" },
+ hideLabel: { control: "boolean" },
+ disabled: { control: "boolean" },
+ },
+ render: (args) => ,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ columns: ticketColumns,
+ availableColumns: ticketFields,
+ onColumnsChange: () => {},
+ onReset: () => {},
+ },
+};
+
+export const IconOnlyTrigger: Story = {
+ args: { ...Default.args, hideLabel: true },
+};
+
+export const AtMinimumColumns: Story = {
+ args: {
+ ...Default.args,
+ columns: [ticketColumns[0]],
+ },
+};
+
+export const AllColumnsAdded: Story = {
+ args: { ...Default.args, columns: ticketFields },
+};
+
+export const WithoutReset: Story = {
+ args: { ...Default.args, onReset: undefined },
+};
+
+export const Pinnable: Story = {
+ args: {
+ ...Default.args,
+ pinnable: true,
+ columns: ticketColumns.map((column, index) => ({
+ ...column,
+ pinned: index < 2,
+ })),
+ },
+};
+
+export const PinLimitReached: Story = {
+ args: {
+ ...Pinnable.args,
+ maxPinned: 2,
+ },
+};
+
+export const NotReorderable: Story = {
+ args: { ...Default.args, reorderable: false },
+};
+
+export const Disabled: Story = {
+ args: { ...Default.args, disabled: true },
+};
+
+export const TranslatedLabels: Story = {
+ args: {
+ ...Default.args,
+ labels: {
+ trigger: "Colonnes",
+ addColumn: "Ajouter une colonne",
+ resetToDefault: "Réinitialiser",
+ remove: "Supprimer la colonne",
+ reorder: "Réordonner la colonne",
+ pin: "Épingler la colonne",
+ unpin: "Désépingler la colonne",
+ pinLimit: "Limite d'épinglage atteinte",
+ },
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/columnSelector/columnSelector.tsx b/packages/frappe-ui-react/src/components/columnSelector/columnSelector.tsx
new file mode 100644
index 00000000..c7af63c4
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/columnSelector/columnSelector.tsx
@@ -0,0 +1,213 @@
+import { useCallback, useMemo } from "react";
+import { Popover } from "@base-ui/react/popover";
+import {
+ DndContext,
+ KeyboardSensor,
+ MouseSensor,
+ TouchSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ arrayMove,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+
+import { Autocomplete } from "../autoComplete";
+import type { AutocompleteChangeSelection } from "../autoComplete";
+import { Button } from "../button";
+import { AddSm, Reset, VerticalColumn } from "../../icons";
+import ColumnSelectorRow from "./columnSelectorRow";
+import type { ColumnSelectorProps, SelectorColumn } from "./types";
+import {
+ DEFAULT_MIN_COLUMNS,
+ addColumn,
+ canRemoveColumn,
+ groupPinned,
+ removeColumn,
+ resolveLabels,
+ togglePinned,
+ unusedColumns,
+} from "./utils";
+
+/**
+ * Popover for choosing which columns a list view shows.
+ *
+ * Fully controlled: the component never owns column state. It renders
+ * `columns`, and every add / remove emits the next value through
+ * `onColumnsChange` for the consumer to store and persist.
+ */
+export default function ColumnSelector({
+ columns: unorderedColumns,
+ onColumnsChange,
+ availableColumns,
+ minColumns = DEFAULT_MIN_COLUMNS,
+ reorderable = true,
+ pinnable = false,
+ maxPinned,
+ onReset,
+ hideLabel = false,
+ disabled = false,
+ labels: labelOverrides,
+}: ColumnSelectorProps) {
+ const labels = useMemo(() => resolveLabels(labelOverrides), [labelOverrides]);
+ const columns = useMemo(
+ () => (pinnable ? groupPinned(unorderedColumns) : unorderedColumns),
+ [pinnable, unorderedColumns]
+ );
+ const pinnedCount = pinnable
+ ? columns.filter((column) => column.pinned).length
+ : 0;
+ const canPin = maxPinned === undefined || pinnedCount < maxPinned;
+ const removable = canRemoveColumn(columns, minColumns);
+ const addable = useMemo(
+ () => unusedColumns(availableColumns, columns),
+ [availableColumns, columns]
+ );
+
+ const handleRemove = useCallback(
+ (column: SelectorColumn) => {
+ const next = removeColumn(columns, column.value, minColumns);
+ if (next === columns) return;
+ onColumnsChange(next);
+ },
+ [columns, minColumns, onColumnsChange]
+ );
+
+ const sensors = useSensors(
+ useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
+ useSensor(TouchSensor, {
+ activationConstraint: { delay: 200, tolerance: 5 },
+ }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+ );
+ const columnIds = useMemo(() => columns.map((c) => c.value), [columns]);
+
+ const handleDragEnd = useCallback(
+ ({ active, over }: DragEndEvent) => {
+ if (!over || active.id === over.id) return;
+ const from = columnIds.indexOf(String(active.id));
+ const to = columnIds.indexOf(String(over.id));
+ if (from < 0 || to < 0) return;
+ if (pinnable && !!columns[from].pinned !== !!columns[to].pinned) return;
+ onColumnsChange(arrayMove(columns, from, to));
+ },
+ [columnIds, columns, onColumnsChange, pinnable]
+ );
+
+ const handleTogglePinned = useCallback(
+ (column: SelectorColumn) =>
+ onColumnsChange(togglePinned(columns, column.value)),
+ [columns, onColumnsChange]
+ );
+
+ const handleAdd = useCallback(
+ (selection: AutocompleteChangeSelection) => {
+ if (!selection || Array.isArray(selection)) return;
+ const column = addable.find((c) => c.value === selection.value);
+ if (!column) return;
+ onColumnsChange(addColumn(columns, column));
+ },
+ [addable, columns, onColumnsChange]
+ );
+
+ return (
+
+
+ }
+ icon={
+ hideLabel
+ ? () =>
+ : undefined
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+ {columns.map((column, index) => (
+
+ {pinnedCount > 0 && index === pinnedCount && (
+
+ )}
+
+
+ ))}
+
+
+
+ {(addable.length > 0 || onReset) && (
+
+ {addable.length > 0 && (
+
handleAdd(selection)}
+ >
+ }
+ />
+
+ )}
+ {onReset && (
+
}
+ onClick={onReset}
+ />
+ )}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/frappe-ui-react/src/components/columnSelector/columnSelectorRow.tsx b/packages/frappe-ui-react/src/components/columnSelector/columnSelectorRow.tsx
new file mode 100644
index 00000000..afab7217
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/columnSelector/columnSelectorRow.tsx
@@ -0,0 +1,106 @@
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+
+import { Button } from "../button";
+import { Tooltip } from "../tooltip";
+import { Close, DragVertical, Pin, Unpin } from "../../icons";
+import { cn } from "../../utils";
+import type { ColumnSelectorLabels, SelectorColumn } from "./types";
+
+export interface ColumnSelectorRowProps {
+ column: SelectorColumn;
+ labels: ColumnSelectorLabels;
+ reorderable?: boolean;
+ removable?: boolean;
+ pinnable?: boolean;
+ pinDisabled?: boolean;
+ onRemove?: (column: SelectorColumn) => void;
+ onTogglePinned?: (column: SelectorColumn) => void;
+}
+
+export default function ColumnSelectorRow({
+ column,
+ labels,
+ reorderable = false,
+ removable = true,
+ pinnable = false,
+ pinDisabled = false,
+ onRemove,
+ onTogglePinned,
+}: ColumnSelectorRowProps) {
+ const pinLabel = pinDisabled
+ ? labels.pinLimit
+ : column.pinned
+ ? labels.unpin
+ : labels.pin;
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ setActivatorNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: column.value, disabled: !reorderable });
+
+ return (
+
+
+ {reorderable && (
+
+
+
+ )}
+
{column.label}
+
+
+ {pinnable && (
+
+ !pinDisabled && onTogglePinned?.(column)}
+ >
+ {column.pinned ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+ {removable && (
+
onRemove?.(column)}
+ >
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/frappe-ui-react/src/components/columnSelector/index.ts b/packages/frappe-ui-react/src/components/columnSelector/index.ts
new file mode 100644
index 00000000..a3778b81
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/columnSelector/index.ts
@@ -0,0 +1,3 @@
+export { default as ColumnSelector } from "./columnSelector";
+export * from "./types";
+export { DEFAULT_MIN_COLUMNS } from "./utils";
diff --git a/packages/frappe-ui-react/src/components/columnSelector/types.ts b/packages/frappe-ui-react/src/components/columnSelector/types.ts
new file mode 100644
index 00000000..26fe9e9f
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/columnSelector/types.ts
@@ -0,0 +1,90 @@
+/** A column the selector can show, add or remove. */
+export type SelectorColumn = {
+ /** Text shown in the selector row and in the Add Column search. */
+ label: string;
+ /** Stable identifier; used for keys and de-duplication. */
+ value: string;
+ /**
+ * Keeps the column in the pinned group at the top of the list. Only
+ * meaningful when `ColumnSelectorProps.pinnable` is set.
+ */
+ pinned?: boolean;
+};
+
+/** Every user-facing string, overridable for i18n via `ColumnSelectorProps.labels`. */
+export type ColumnSelectorLabels = {
+ /** Trigger button text and its `aria-label`. */
+ trigger: string;
+ /** Footer action that opens the Add Column search. */
+ addColumn: string;
+ /** Footer action wired to `onReset`. */
+ resetToDefault: string;
+ /** Prefix of each row's remove-button `aria-label`, followed by the column label. */
+ remove: string;
+ /** Prefix of each row's drag-handle `aria-label`, followed by the column label. */
+ reorder: string;
+ /** Pin toggle tooltip and `aria-label` prefix for an unpinned column. */
+ pin: string;
+ /** Pin toggle tooltip and `aria-label` prefix for a pinned column. */
+ unpin: string;
+ /** Pin toggle tooltip once `ColumnSelectorProps.maxPinned` is reached. */
+ pinLimit: string;
+};
+
+/** Props for `ColumnSelector`. */
+export interface ColumnSelectorProps {
+ /** Active columns, in display order. */
+ columns: SelectorColumn[];
+ /** Receives the full next `columns` array after an add or remove. */
+ onColumnsChange: (columns: SelectorColumn[]) => void;
+ /**
+ * Every column the user may add. Entries whose `value` is already in
+ * `columns` are hidden from the Add Column search.
+ */
+ availableColumns: SelectorColumn[];
+
+ /**
+ * Show a drag handle on each row and allow reordering by pointer or
+ * keyboard (focus the handle, Space to lift, arrows to move, Space to drop).
+ * @default true
+ */
+ reorderable?: boolean;
+ /**
+ * Show a pin toggle on each row. Pinned columns are listed first, above a
+ * divider, and can only be reordered within their group. Toggling emits the
+ * next `columns` through `onColumnsChange` with the column's `pinned` flag
+ * flipped and the column moved to the group boundary.
+ * @default false
+ */
+ pinnable?: boolean;
+ /**
+ * Pin toggles on unpinned rows are disabled once this many columns are
+ * pinned. Unlimited when omitted.
+ */
+ maxPinned?: number;
+ /**
+ * Remove buttons are hidden once `columns.length` reaches this count.
+ * @default 1
+ */
+ minColumns?: number;
+ /**
+ * Renders the "Reset to Default" footer action when provided. The
+ * component only fires it; restoring the default `columns` is
+ * the consumer's job.
+ */
+ onReset?: () => void;
+
+ /**
+ * Render the trigger as an icon-only button. The label stays available
+ * to assistive tech via `aria-label`.
+ * @default false
+ */
+ hideLabel?: boolean;
+ /**
+ * Disables the trigger.
+ * @default false
+ */
+ disabled?: boolean;
+ /** Partial overrides merged over the English defaults. */
+ labels?: Partial;
+}
diff --git a/packages/frappe-ui-react/src/components/columnSelector/utils.ts b/packages/frappe-ui-react/src/components/columnSelector/utils.ts
new file mode 100644
index 00000000..c64166c3
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/columnSelector/utils.ts
@@ -0,0 +1,80 @@
+import type { ColumnSelectorLabels, SelectorColumn } from "./types";
+
+export const DEFAULT_MIN_COLUMNS = 1;
+
+export const DEFAULT_LABELS: ColumnSelectorLabels = {
+ trigger: "Columns",
+ addColumn: "Add Column",
+ resetToDefault: "Reset to Default",
+ remove: "Remove column",
+ reorder: "Reorder column",
+ pin: "Pin column",
+ unpin: "Unpin column",
+ pinLimit: "Pin limit reached",
+};
+
+export function resolveLabels(
+ labels?: Partial
+): ColumnSelectorLabels {
+ return labels ? { ...DEFAULT_LABELS, ...labels } : DEFAULT_LABELS;
+}
+
+export function canRemoveColumn(
+ columns: SelectorColumn[],
+ minColumns: number = DEFAULT_MIN_COLUMNS
+): boolean {
+ return columns.length > minColumns;
+}
+
+export function removeColumn(
+ columns: T[],
+ value: string,
+ minColumns: number = DEFAULT_MIN_COLUMNS
+): T[] {
+ if (!canRemoveColumn(columns, minColumns)) {
+ return columns;
+ }
+ return columns.filter((column) => column.value !== value);
+}
+
+export function unusedColumns(
+ availableColumns: SelectorColumn[],
+ columns: SelectorColumn[]
+): SelectorColumn[] {
+ const used = new Set(columns.map((column) => column.value));
+ return availableColumns.filter((column) => !used.has(column.value));
+}
+
+export function addColumn(
+ columns: T[],
+ column: T
+): T[] {
+ if (columns.some((existing) => existing.value === column.value)) {
+ return columns;
+ }
+ return [...columns, column];
+}
+
+export function groupPinned(columns: T[]): T[] {
+ return [
+ ...columns.filter((column) => column.pinned),
+ ...columns.filter((column) => !column.pinned),
+ ];
+}
+
+export function togglePinned(
+ columns: T[],
+ value: string
+): T[] {
+ const column = columns.find((existing) => existing.value === value);
+ if (!column) {
+ return columns;
+ }
+ const rest = columns.filter((existing) => existing.value !== value);
+ const toggled = { ...column, pinned: !column.pinned };
+ return [
+ ...rest.filter((existing) => existing.pinned),
+ toggled,
+ ...rest.filter((existing) => !existing.pinned),
+ ];
+}
diff --git a/packages/frappe-ui-react/src/components/combobox/combobox.interactions.stories.tsx b/packages/frappe-ui-react/src/components/combobox/combobox.interactions.stories.tsx
new file mode 100644
index 00000000..c97d0c5c
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/combobox/combobox.interactions.stories.tsx
@@ -0,0 +1,274 @@
+import * as React from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
+
+import { Combobox } from "./index";
+
+const meta: Meta = {
+ title: "Components/Combobox/Interactions",
+ component: Combobox,
+ 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" },
+ { label: "Jane Doe", value: "jane-doe" },
+ { label: "John Smith", value: "john-smith" },
+ { label: "Bob Johnson", value: "bob-johnson" },
+ { label: "Alice Brown", value: "alice-brown" },
+];
+
+const groupedOptions = [
+ {
+ group: "Fruits",
+ options: [
+ { label: "Apple", value: "apple" },
+ { label: "Banana", value: "banana", disabled: true },
+ ],
+ },
+ {
+ group: "Vegetables",
+ options: [{ label: "Carrot", value: "carrot" }],
+ },
+];
+
+export const OptionSelection: Story = {
+ args: {
+ options: simpleOptions,
+ value: "",
+ placeholder: "Select an item",
+ onChange: fn(),
+ },
+ render: function Render(args) {
+ const [value, setValue] = React.useState("");
+
+ return (
+
+ {
+ setValue(nextValue);
+ args.onChange?.(nextValue, selectedOption);
+ }}
+ />
+
+ );
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const page = within(document.body);
+ const input = canvas.getByRole("combobox");
+
+ await userEvent.click(input);
+ await userEvent.type(input, "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(input).toHaveValue("Cherry");
+ });
+ },
+};
+
+export const GroupedAndDisabled: Story = {
+ args: {
+ options: groupedOptions,
+ value: "",
+ placeholder: "Select an item",
+ openOnFocus: true,
+ },
+ render: function Render(args) {
+ const [value, setValue] = React.useState("");
+
+ return (
+
+
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const page = within(document.body);
+ const input = canvas.getByRole("combobox");
+
+ await userEvent.click(input);
+
+ 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 ControlledSearchLoading: Story = {
+ args: {
+ options: directoryOptions,
+ value: "",
+ placeholder: "Search people...",
+ openOnFocus: true,
+ },
+ 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 (
+
+
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const page = within(document.body);
+ const input = canvas.getByRole("combobox");
+
+ await userEvent.click(input);
+ await waitFor(() => {
+ expect(page.getByText("John Doe")).toBeInTheDocument();
+ });
+
+ await userEvent.clear(input);
+ await userEvent.type(input, "smith");
+
+ await waitFor(() => {
+ expect(page.getByText("John Smith")).toBeInTheDocument();
+ });
+
+ expect(page.queryByText("John Doe")).not.toBeInTheDocument();
+ },
+};
+
+export const Clearable: Story = {
+ args: {
+ options: simpleOptions,
+ value: "cherry",
+ placeholder: "Select an item",
+ clearable: true,
+ onChange: fn(),
+ },
+ render: function Render(args) {
+ const [value, setValue] = React.useState("cherry");
+
+ return (
+
+ {
+ setValue(nextValue);
+ args.onChange?.(nextValue, selectedOption);
+ }}
+ />
+
+ );
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("combobox");
+ const clearButton = canvas.getByRole("button", {
+ name: "Clear selection",
+ });
+
+ await userEvent.click(clearButton);
+
+ await expect(args.onChange).toHaveBeenCalledWith(null, null);
+
+ await waitFor(() => {
+ expect(input).toHaveValue("");
+ });
+ },
+};
+
+export const TooltipOnTruncate: Story = {
+ args: {
+ options: [
+ "A very long option label that gets truncated in the dropdown list",
+ { label: "Short", value: "short" },
+ ],
+ value: "",
+ placeholder: "Select an item",
+ openOnFocus: true,
+ tooltipOnTruncate: true,
+ },
+ render: function Render(args) {
+ const [value, setValue] = React.useState("");
+ return (
+
+
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("combobox");
+
+ await userEvent.click(input);
+
+ const option = await screen.findByText(
+ "A very long option label that gets truncated in the dropdown list"
+ );
+
+ await userEvent.hover(option);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("tooltip-popup")).toBeInTheDocument();
+ });
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/combobox/combobox.stories.tsx b/packages/frappe-ui-react/src/components/combobox/combobox.stories.tsx
index 7c0fa117..bdc5e6d1 100644
--- a/packages/frappe-ui-react/src/components/combobox/combobox.stories.tsx
+++ b/packages/frappe-ui-react/src/components/combobox/combobox.stories.tsx
@@ -1,4 +1,4 @@
-import * as React from "react";
+import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Combobox } from "./index";
@@ -32,6 +32,27 @@ const meta: Meta = {
control: "boolean",
description: "Whether the combobox is disabled",
},
+ openOnFocus: {
+ control: "boolean",
+ description:
+ "Whether the popup should open when the input receives focus",
+ },
+ searchValue: {
+ control: "text",
+ description: "Controlled search text shown in the combobox input",
+ },
+ onSearchChange: {
+ action: "searchChanged",
+ description: "Event handler called when the search text changes",
+ },
+ loading: {
+ control: "boolean",
+ description: "Whether externally managed options are still loading",
+ },
+ emptyMessage: {
+ control: "text",
+ description: "Custom message to show when no options are available",
+ },
onChange: {
action: "changed",
description: "Event handler called when the selected value changes",
@@ -40,6 +61,11 @@ const meta: Meta = {
control: "text",
description: "Additional CSS classes to apply to the combobox container",
},
+ tooltipOnTruncate: {
+ control: "boolean",
+ description:
+ "Show a tooltip with the full option label on hover, only when the label is truncated",
+ },
},
};
export default meta;
@@ -136,6 +162,13 @@ const complexObjects = [
},
];
+const directoryOptions = [
+ { label: "John Doe", value: "john-doe" },
+ { label: "Jane Smith", value: "jane-smith" },
+ { label: "Bob Johnson", value: "bob-johnson" },
+ { label: "Alice Brown", value: "alice-brown" },
+];
+
export const SimpleStringOptions: Story = {
args: {
options: simpleOptions,
@@ -144,7 +177,7 @@ export const SimpleStringOptions: Story = {
onChange: () => {},
},
render: (args) => {
- const [val, setVal] = React.useState("");
+ const [val, setVal] = useState("");
return (
Simple Options
@@ -165,7 +198,7 @@ export const ObjectOptions: Story = {
onChange: () => {},
},
render: (args) => {
- const [val, setVal] = React.useState
("");
+ const [val, setVal] = useState("");
return (
Object Options
@@ -187,7 +220,7 @@ export const WithIcons: Story = {
onChange: () => {},
},
render: (args) => {
- const [val, setVal] = React.useState
("");
+ const [val, setVal] = useState("");
return (
@@ -211,7 +244,7 @@ export const Grouped: Story = {
onChange: () => {},
},
render: (args) => {
- const [val, setVal] = React.useState("");
+ const [val, setVal] = useState("");
return (
@@ -226,6 +259,34 @@ export const Grouped: Story = {
},
};
+export const TooltipOnTruncate: Story = {
+ name: "Tooltip on Truncated Options",
+ args: {
+ options: [
+ "A very long project name that will definitely get truncated in the dropdown list",
+ { label: "Short name", value: "short" },
+ ],
+ value: "",
+ placeholder: "Select a project...",
+ onChange: () => {},
+ tooltipOnTruncate: true,
+ },
+ render: (args) => {
+ const [val, setVal] = useState("");
+ return (
+
+
+ Tooltip on Truncated Options
+
+
+
+ Selected: {val || "None"}
+
+
+ );
+ },
+};
+
export const DisabledState: Story = {
args: {
options: simpleOptions,
@@ -253,7 +314,7 @@ export const PreselectedValue: Story = {
onChange: () => {},
},
render: (args) => {
- const [val, setVal] = React.useState("john-doe");
+ const [val, setVal] = useState("john-doe");
return (
@@ -293,7 +354,7 @@ export const ComplexObject: Story = {
onChange: () => {},
},
render: (args) => {
- const [val, setVal] = React.useState("");
+ const [val, setVal] = useState("");
const selected = complexObjects.find((o) => o.value === val);
return (
@@ -314,3 +375,70 @@ export const ComplexObject: Story = {
);
},
};
+
+export const ControlledSearch: Story = {
+ args: {
+ options: directoryOptions,
+ value: "",
+ placeholder: "Search people...",
+ openOnFocus: true,
+ onChange: () => {},
+ },
+ render: (args) => {
+ const [value, setValue] = useState
("");
+ const [searchValue, setSearchValue] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [options, setOptions] =
+ useState(directoryOptions);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ setLoading(true);
+
+ const timeoutId = window.setTimeout(() => {
+ if (cancelled) {
+ return;
+ }
+
+ const normalizedSearch = searchValue.trim().toLowerCase();
+ setOptions(
+ normalizedSearch
+ ? directoryOptions.filter((option) =>
+ option.label.toLowerCase().includes(normalizedSearch)
+ )
+ : directoryOptions
+ );
+ setLoading(false);
+ }, 400);
+
+ return () => {
+ cancelled = true;
+ window.clearTimeout(timeoutId);
+ };
+ }, [searchValue]);
+
+ return (
+
+
+ Controlled Search
+
+
{
+ setValue(nextValue);
+ args.onChange?.(nextValue);
+ }}
+ />
+
+ Selected: {value || "None"}
+
+
+ );
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/combobox/combobox.tsx b/packages/frappe-ui-react/src/components/combobox/combobox.tsx
index 3b5c919d..934fdf8a 100644
--- a/packages/frappe-ui-react/src/components/combobox/combobox.tsx
+++ b/packages/frappe-ui-react/src/components/combobox/combobox.tsx
@@ -1,212 +1,346 @@
-import { useCallback, useMemo, useState } from "react";
+/**
+ * External dependencies.
+ */
import {
- Combobox as ComboboxRoot,
- ComboboxInput,
- ComboboxButton,
- ComboboxOptions,
- ComboboxOption,
-} from "@headlessui/react";
-import type { ComboboxProps, SimpleOption } from "./types";
-import { Check } from "lucide-react";
-
-// Utility for display
-const getLabel = (option: SimpleOption) =>
- typeof option === "string" ? option : option.label;
-const getValue = (option: SimpleOption) =>
- typeof option === "string" ? option : option.value;
-const isDisabled = (option: SimpleOption) =>
- typeof option === "object" && !!option.disabled;
-const getIcon = (option: SimpleOption) =>
- typeof option === "object" ? option.icon : undefined;
+ startTransition,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { Combobox as BaseCombobox } from "@base-ui/react";
+
+/**
+ * Internal dependencies.
+ */
+import LoadingIndicator from "../loadingIndicator";
+import { Tooltip } from "../tooltip";
+import type {
+ ComboboxOption as ComboboxItem,
+ ComboboxProps,
+ SimpleOption,
+} from "./types";
+import {
+ filterOptions,
+ flattenOptions,
+ getDescription,
+ getIcon,
+ getLabel,
+ getValue,
+ isDisabled,
+ isGroupedOption,
+} from "./utils";
+import { cn } from "../../utils";
+import { Check, SmallClose, SmallDown } from "../../icons";
+
+const ComboboxOptionItem: React.FC<{
+ option: SimpleOption;
+ tooltipOnTruncate?: boolean;
+}> = ({ option, tooltipOnTruncate }) => {
+ const description = getDescription(option);
+ const label = getLabel(option);
+
+ const labelNode = (
+
+ {label}
+
+ );
+
+ return (
+
+ {getIcon(option) && {getIcon(option)} }
+ {description ? (
+
+ {tooltipOnTruncate ? (
+
+ {labelNode}
+
+ ) : (
+ labelNode
+ )}
+
+ {description}
+
+
+ ) : tooltipOnTruncate ? (
+
+ {labelNode}
+
+ ) : (
+ labelNode
+ )}
+
+
+
+
+ );
+};
export const Combobox: React.FC = ({
+ id,
options,
value,
placeholder,
disabled,
+ clearable = false,
+ openOnFocus = false,
+ searchValue,
+ onSearchChange,
+ loading = false,
+ emptyMessage,
onChange,
className,
+ inputClassName,
+ popupClassName,
+ tooltipOnTruncate = false,
}) => {
- const allOptionsFlat: SimpleOption[] = useMemo(() => {
- const flat: SimpleOption[] = [];
- options.forEach((opt) => {
- if (typeof opt === "object" && "group" in opt) {
- flat.push(...opt.options);
- } else {
- flat.push(opt as SimpleOption);
- }
+ const [open, setOpen] = useState(false);
+ const [internalQuery, setInternalQuery] = useState("");
+ const [selectedLabelCache, setSelectedLabelCache] = useState<{
+ label: string;
+ value: string;
+ } | null>(null);
+ const [settledRemoteOptions, setSettledRemoteOptions] =
+ useState(options);
+ const isSearchControlled = searchValue !== undefined;
+ const query = isSearchControlled ? (searchValue ?? "") : internalQuery;
+
+ useEffect(() => {
+ if (!isSearchControlled || loading) {
+ return;
+ }
+
+ // Defer settled-options updates so typing and focus interactions stay responsive.
+ startTransition(() => {
+ setSettledRemoteOptions(options);
});
- return flat;
- }, [options]);
+ }, [isSearchControlled, loading, options]);
+
+ const displayedOptions = useMemo(() => {
+ if (!isSearchControlled || !loading || options.length > 0) {
+ return options;
+ }
+
+ return settledRemoteOptions;
+ }, [isSearchControlled, loading, options, settledRemoteOptions]);
+
+ const allOptionsFlat = useMemo(
+ () => flattenOptions(displayedOptions),
+ [displayedOptions]
+ );
- // For filtering/search
- const [query, setQuery] = useState("");
+ const updateQuery = useCallback(
+ (nextQuery: string) => {
+ if (searchValue === undefined) {
+ setInternalQuery(nextQuery);
+ }
+ onSearchChange?.(nextQuery);
+ },
+ [onSearchChange, searchValue]
+ );
const selectedOption = useMemo(() => {
if (!value) return null;
return allOptionsFlat.find((opt) => getValue(opt) === value) ?? null;
}, [value, allOptionsFlat]);
+ const selectedLabel = selectedOption
+ ? getLabel(selectedOption)
+ : value && selectedLabelCache?.value === value
+ ? selectedLabelCache.label
+ : "";
+ const displayQuery = !query && selectedLabel ? selectedLabel : query;
const filteredOptions = useMemo(() => {
- if (!query || (selectedOption && query === getLabel(selectedOption))) {
- return options;
+ if (isSearchControlled) {
+ return loading
+ ? filterOptions(displayedOptions, query)
+ : displayedOptions;
}
- return options
- .map((opt) =>
- typeof opt === "object" && "group" in opt
- ? {
- ...opt,
- options: opt.options.filter((o) =>
- getLabel(o).toLowerCase().includes(query.toLowerCase())
- ),
- }
- : opt
- )
- .filter((opt) =>
- typeof opt === "string"
- ? opt.toLowerCase().includes(query.toLowerCase())
- : "group" in opt
- ? opt.options.length > 0
- : opt.label.toLowerCase().includes(query.toLowerCase())
- );
- }, [options, query, selectedOption]);
+ if (!query) {
+ return displayedOptions;
+ }
+
+ return filterOptions(displayedOptions, query);
+ }, [displayedOptions, isSearchControlled, loading, query]);
+
+ const renderedOptionsFlat = flattenOptions(filteredOptions);
const handleChange = useCallback(
- (val: string | null) => {
- const selected = val
- ? allOptionsFlat.find((opt) => getValue(opt) === val) || null
- : null;
+ (selected: SimpleOption | null) => {
+ const nextValue = selected ? getValue(selected) : null;
+ const nextLabel = selected ? getLabel(selected) : "";
- onChange?.(val, selected);
- setQuery(selected ? getLabel(selected) : "");
+ setSelectedLabelCache(
+ nextValue ? { value: nextValue, label: nextLabel } : null
+ );
+
+ onChange?.(nextValue, selected);
+
+ updateQuery("");
},
- [allOptionsFlat, onChange]
+ [onChange, updateQuery]
);
- const displayValue = useCallback(
- (val: string) => {
- if (!val) return "";
- const opt = allOptionsFlat.find((opt) => getValue(opt) === val);
- return opt ? getLabel(opt) : "";
+ const handleFocus = useCallback(() => {
+ if (openOnFocus) {
+ setOpen(true);
+ }
+ }, [openOnFocus]);
+
+ const handleInputValueChange = useCallback(
+ (nextQuery: string, details: { reason?: string }) => {
+ if (details.reason === "item-press") {
+ return;
+ }
+
+ updateQuery(nextQuery);
},
- [allOptionsFlat]
+ [updateQuery]
+ );
+
+ const resolvedEmptyMessage =
+ emptyMessage ?? (query ? `No results found for "${query}"` : undefined);
+
+ const showEmptyState =
+ !loading && filteredOptions.length === 0 && Boolean(resolvedEmptyMessage);
+ const showOptionsPanel = filteredOptions.length > 0 || showEmptyState;
+
+ const hasSelectedIcon = Boolean(selectedOption && getIcon(selectedOption));
+ const showClear = clearable && Boolean(value);
+
+ const isSameOption = useCallback(
+ (option: SimpleOption | null, candidate: SimpleOption | null) => {
+ if (!option || !candidate) {
+ return option === candidate;
+ }
+
+ return getValue(option) === getValue(candidate);
+ },
+ []
);
return (
-
-
+
- {/* Show icon in input if selected option has icon */}
{selectedOption && getIcon(selectedOption) && (
-
+
{getIcon(selectedOption)}
)}
- setQuery(e.target.value)}
- onFocus={() =>
- setQuery(selectedOption ? getLabel(selectedOption) : "")
- }
+ onFocus={handleFocus}
autoComplete="off"
/>
-
-
-
-
-
-
-
- {filteredOptions.length === 0 && (
-
- No results found for "{query}"
-
+ {showClear && (
+ handleChange(null)}
+ >
+
+
)}
- {filteredOptions.map((opt) =>
- typeof opt === "object" && "group" in opt ? (
-
-
- {opt.group}
-
- {opt.options.map((option) => (
-
- `
- text-ink-gray-8 flex items-center gap-2 px-2.5 py-1.5 text-base cursor-pointer truncate rounded
- ${disabled ? "opacity-50" : ""}
- ${active ? "bg-surface-gray-3" : ""}
- `
- }
- >
- {getIcon(option) && (
- {getIcon(option)}
- )}
- {getLabel(option)}
- {selectedOption &&
- getValue(option) === getValue(selectedOption) && (
-
-
-
- )}
-
- ))}
-
- ) : (
-
- `
- text-ink-gray-8 flex items-center gap-2 px-2.5 py-1.5 text-base cursor-pointer truncate rounded
- ${disabled ? "opacity-50" : ""}
- ${active ? "bg-surface-gray-3" : ""}
- `
- }
+
+ {loading && }
+
+
+
+ {showOptionsPanel && (
+
+
+
- {getIcon(opt as SimpleOption) && (
- {getIcon(opt as SimpleOption)}
+ {showEmptyState && (
+
+ {resolvedEmptyMessage}
+
)}
- {getLabel(opt as SimpleOption)}
- {selectedOption &&
- getValue(opt as SimpleOption) ===
- getValue(selectedOption) && (
-
-
-
+
+ {filteredOptions.map((opt) =>
+ isGroupedOption(opt) ? (
+
+
+ {opt.group}
+
+ {opt.options.map((option) => (
+
+ ))}
+
+ ) : (
+
+ )
)}
-
- )
- )}
-
+
+
+
+
+ )}
-
+
);
};
diff --git a/packages/frappe-ui-react/src/components/combobox/tests/index.tsx b/packages/frappe-ui-react/src/components/combobox/tests/index.tsx
deleted file mode 100644
index c844a87d..00000000
--- a/packages/frappe-ui-react/src/components/combobox/tests/index.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { render, screen, fireEvent } from "@testing-library/react";
-import "@testing-library/jest-dom";
-import userEvent from "@testing-library/user-event";
-
-import { Combobox } from "../combobox";
-
-global.ResizeObserver = jest.fn().mockImplementation(() => ({
- observe: jest.fn(),
- unobserve: jest.fn(),
- disconnect: jest.fn(),
-}));
-
-describe("Combobox", () => {
- const simpleOptions = ["Option 1", "Option 2", "Option 3"];
- const complexOptions = [
- { label: "Option 1", value: "1", icon: 🌟 },
- { label: "Option 2", value: "2" },
- { label: "Option 3", value: "3", disabled: true },
- ];
- const groupedOptions = [
- {
- group: "Group 1",
- options: [
- { label: "Option 1", value: "1" },
- { label: "Option 2", value: "2" },
- ],
- },
- {
- group: "Group 2",
- options: [{ label: "Option 3", value: "3" }],
- },
- ];
-
- it("renders with simple string options", () => {
- render( );
- expect(screen.getByRole("combobox")).toBeInTheDocument();
- });
-
- it("handles complex options with icons", () => {
- render( );
- expect(screen.getByText("🌟")).toBeInTheDocument();
- });
-
- it("filters options based on search query", async () => {
- render( );
- const input = screen.getByRole("combobox");
- fireEvent.change(input, { target: { value: "Option 1" } });
- expect(screen.getByText("Option 1")).toBeInTheDocument();
- expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
- });
-
- it("handles disabled options", () => {
- render( );
- const input = screen.getByRole("combobox");
- fireEvent.change(input, { target: { value: "Option 3" } });
- const disabled = screen.getByText("Option 3");
- expect(disabled.parentElement).toHaveClass("opacity-50");
- });
-
- it("renders grouped options correctly", () => {
- render( );
- const input = screen.getByRole("combobox");
- fireEvent.change(input, { target: { value: "Option" } });
- expect(screen.getByText("Group 1")).toBeInTheDocument();
- expect(screen.getByText("Group 2")).toBeInTheDocument();
- });
-
- it("calls onChange when option is selected", async () => {
- const handleChange = jest.fn();
- render( );
- const input = screen.getByRole("combobox");
- await userEvent.type(input, "Option 1");
- await userEvent.click(screen.getByText("Option 1"));
- expect(handleChange).toHaveBeenCalledWith("Option 1", "Option 1");
- });
-
- it("displays placeholder when no value is selected", () => {
- render( );
- expect(screen.getByPlaceholderText("Select an option")).toBeInTheDocument();
- });
-});
diff --git a/packages/frappe-ui-react/src/components/combobox/types.ts b/packages/frappe-ui-react/src/components/combobox/types.ts
index f5026662..3a835f7a 100644
--- a/packages/frappe-ui-react/src/components/combobox/types.ts
+++ b/packages/frappe-ui-react/src/components/combobox/types.ts
@@ -3,6 +3,7 @@ export type SimpleOption =
| {
label: string;
value: string;
+ description?: string;
icon?: React.ReactNode;
disabled?: boolean;
};
@@ -11,13 +12,23 @@ export type GroupedOption = { group: string; options: SimpleOption[] };
export type ComboboxOption = SimpleOption | GroupedOption;
export interface ComboboxProps {
+ id?: string;
options: ComboboxOption[];
value?: string | null;
placeholder?: string;
disabled?: boolean;
+ clearable?: boolean;
+ openOnFocus?: boolean;
+ searchValue?: string;
+ onSearchChange?: (value: string) => void;
+ loading?: boolean;
+ emptyMessage?: string;
onChange?: (
value: string | null,
selectedOption?: SimpleOption | null
) => void;
className?: string;
+ inputClassName?: string;
+ popupClassName?: string;
+ tooltipOnTruncate?: boolean;
}
diff --git a/packages/frappe-ui-react/src/components/combobox/utils.ts b/packages/frappe-ui-react/src/components/combobox/utils.ts
new file mode 100644
index 00000000..d3c65fd6
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/combobox/utils.ts
@@ -0,0 +1,93 @@
+/**
+ * Internal dependencies.
+ */
+import type {
+ ComboboxOption as ComboboxItem,
+ GroupedOption,
+ SimpleOption,
+} from "./types";
+
+/**
+ * Gets the display label for a simple option.
+ */
+export const getLabel = (option: SimpleOption) =>
+ typeof option === "string" ? option : option.label;
+
+/**
+ * Gets the value key for a simple option.
+ */
+export const getValue = (option: SimpleOption) =>
+ typeof option === "string" ? option : option.value;
+
+/**
+ * Returns whether a simple option is disabled.
+ */
+export const isDisabled = (option: SimpleOption) =>
+ typeof option === "object" && !!option.disabled;
+
+/**
+ * Gets the icon node for a simple option if present.
+ */
+export const getIcon = (option: SimpleOption) =>
+ typeof option === "object" ? option.icon : undefined;
+
+/**
+ * Gets the description text for a simple option if present.
+ */
+export const getDescription = (option: SimpleOption) =>
+ typeof option === "object" ? option.description : undefined;
+
+/**
+ * Returns whether a combobox option is a grouped option.
+ */
+export const isGroupedOption = (
+ option: ComboboxItem
+): option is GroupedOption => typeof option === "object" && "group" in option;
+
+/**
+ * Flattens grouped and ungrouped options into a single simple-options array.
+ */
+export const flattenOptions = (options: ComboboxItem[]) => {
+ const flat: SimpleOption[] = [];
+
+ options.forEach((option) => {
+ if (isGroupedOption(option)) {
+ flat.push(...option.options);
+ return;
+ }
+
+ flat.push(option);
+ });
+
+ return flat;
+};
+
+/**
+ * Filters options by query text and removes empty groups after filtering.
+ */
+export const filterOptions = (options: ComboboxItem[], query: string) => {
+ const normalizedQuery = query.trim().toLowerCase();
+
+ if (!normalizedQuery) {
+ return options;
+ }
+
+ return options
+ .map((option) => {
+ if (!isGroupedOption(option)) {
+ return option;
+ }
+
+ return {
+ ...option,
+ options: option.options.filter((groupedOption) =>
+ getLabel(groupedOption).toLowerCase().includes(normalizedQuery)
+ ),
+ };
+ })
+ .filter((option) =>
+ isGroupedOption(option)
+ ? option.options.length > 0
+ : getLabel(option).toLowerCase().includes(normalizedQuery)
+ );
+};
diff --git a/packages/frappe-ui-react/src/components/datePicker/datePicker.interactions.stories.tsx b/packages/frappe-ui-react/src/components/datePicker/datePicker.interactions.stories.tsx
new file mode 100644
index 00000000..dfb6f711
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/datePicker/datePicker.interactions.stories.tsx
@@ -0,0 +1,168 @@
+/**
+ * External dependencies.
+ */
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
+
+/**
+ * Internal dependencies.
+ */
+import { DatePicker } from "./index";
+import type { DatePickerProps } from "./types";
+
+const meta: Meta = {
+ title: "Components/DatePicker/Interactions/Date",
+ component: DatePicker,
+ parameters: {
+ docs: { source: { type: "dynamic" } },
+ layout: "centered",
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+function ControlledDatePickerStory(args: DatePickerProps) {
+ const [value, setValue] = useState(
+ typeof args.value === "string" ? args.value : ""
+ );
+
+ const handleChange = (nextValue: string | string[]) => {
+ const updatedValue = Array.isArray(nextValue)
+ ? nextValue[0] || ""
+ : nextValue;
+ setValue(updatedValue);
+ args.onChange?.(updatedValue);
+ };
+
+ return (
+
+
+
+ );
+}
+
+export const KeyboardNavigation: Story = {
+ args: {
+ label: "Date",
+ placeholder: "Select date",
+ clearable: false,
+ onChange: fn(),
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+
+ input.focus();
+ await userEvent.keyboard("{Enter}");
+
+ const prevButton = await screen.findByRole("button", {
+ name: "Previous month",
+ });
+ const todayButton = await screen.findByRole("button", { name: "Today" });
+ const nextButton = await screen.findByRole("button", {
+ name: "Next month",
+ });
+
+ prevButton.focus();
+ expect(prevButton).toHaveFocus();
+
+ await userEvent.tab();
+ expect(todayButton).toHaveFocus();
+
+ await userEvent.tab();
+ expect(nextButton).toHaveFocus();
+
+ await userEvent.tab();
+ const focusedDate = document.activeElement as HTMLElement;
+ expect(focusedDate).toHaveAttribute("role", "gridcell");
+
+ await userEvent.keyboard("{Enter}");
+
+ await waitFor(() => {
+ expect(input).not.toHaveValue("");
+ expect(args.onChange).toHaveBeenCalled();
+ });
+ },
+};
+
+export const QuickActions: Story = {
+ args: {
+ label: "Date",
+ placeholder: "Select date",
+ onChange: fn(),
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+ const today = new Date();
+ const tomorrow = new Date(today);
+ const formattedToday = `${today.getFullYear()}-${String(
+ today.getMonth() + 1
+ ).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const formattedTomorrow = `${tomorrow.getFullYear()}-${String(
+ tomorrow.getMonth() + 1
+ ).padStart(2, "0")}-${String(tomorrow.getDate()).padStart(2, "0")}`;
+ const monthLabel = `${today.toLocaleString("en-US", {
+ month: "short",
+ })} ${today.getFullYear()}`;
+
+ await userEvent.click(input);
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Previous month" })
+ );
+
+ const previousMonthDate = screen
+ .getAllByRole("gridcell")
+ .find((gridCell) => gridCell.className.includes("text-ink-gray-8"));
+ expect(previousMonthDate).toBeDefined();
+ await userEvent.click(previousMonthDate as HTMLElement);
+
+ await userEvent.click(input);
+ await userEvent.click(await screen.findByRole("button", { name: "Today" }));
+
+ await waitFor(() => {
+ expect(input).toHaveValue(formattedToday);
+ expect(args.onChange).toHaveBeenLastCalledWith(formattedToday);
+ });
+
+ await userEvent.click(input);
+ const toggleViewButton = await screen.findByRole("button", {
+ name: "Toggle calendar view",
+ });
+ expect(toggleViewButton).toHaveTextContent(monthLabel);
+
+ const selectedToday = screen
+ .getAllByRole("gridcell")
+ .find((gridCell) => gridCell.getAttribute("aria-selected") === "true");
+ expect(selectedToday).toHaveTextContent(String(today.getDate()));
+
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Tomorrow" })
+ );
+
+ await waitFor(() => {
+ expect(input).toHaveValue(formattedTomorrow);
+ expect(args.onChange).toHaveBeenLastCalledWith(formattedTomorrow);
+ });
+
+ await userEvent.click(input);
+ await userEvent.click(await screen.findByRole("button", { name: "Clear" }));
+
+ await waitFor(() => {
+ expect(input).toHaveValue("");
+ expect(args.onChange).toHaveBeenLastCalledWith("");
+ });
+
+ await userEvent.click(input);
+ const resetToggleButton = await screen.findByRole("button", {
+ name: "Toggle calendar view",
+ });
+ expect(resetToggleButton).toHaveTextContent(monthLabel);
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/datePicker/datePicker.stories.tsx b/packages/frappe-ui-react/src/components/datePicker/datePicker.stories.tsx
index cf1008ee..9040c555 100644
--- a/packages/frappe-ui-react/src/components/datePicker/datePicker.stories.tsx
+++ b/packages/frappe-ui-react/src/components/datePicker/datePicker.stories.tsx
@@ -8,6 +8,7 @@ import { useState } from "react";
* Internal dependencies.
*/
import { DatePicker, DateTimePicker, DateRangePicker } from "./index";
+import { dayjs } from "../../utils/dayjs";
const meta: Meta = {
title: "Components/DatePicker",
@@ -20,6 +21,17 @@ const meta: Meta = {
autodocs: true,
},
},
+ argTypes: {
+ variant: {
+ control: { type: "select", options: ["subtle", "outline", "ghost"] },
+ description:
+ "Visual variant of the default trigger input. Ignored when children supplies a custom trigger.",
+ },
+ children: {
+ control: false,
+ description: "Render function for a custom trigger.",
+ },
+ },
};
export default meta;
@@ -30,6 +42,7 @@ type DateRangePickerStory = StoryObj;
const commonArgs = {
label: "Label",
placeholder: "Placeholder",
+ disabled: false,
};
export const Date: DatePickerStory = {
@@ -68,6 +81,76 @@ export const Date: DatePickerStory = {
},
};
+const DATE_PRESETS: { label: string; value: string }[] = [
+ { label: "New Year", value: "2024-01-01" },
+ { label: "Christmas", value: "2024-12-25" },
+];
+
+export const DateWithFooter: DatePickerStory = {
+ args: {
+ ...commonArgs,
+ value: "",
+ },
+ render: (args) => {
+ const [dateValue, setDateValue] = useState("");
+ return (
+
+
+ setDateValue(Array.isArray(val) ? val[0] || "" : val)
+ }
+ footer={(props, { setValue, clear, close }) => (
+
+ {DATE_PRESETS.map((preset) => (
+ {
+ setValue(preset.value);
+ close();
+ }}
+ >
+ {preset.label}
+
+ ))}
+
+ Reset
+
+
+ )}
+ />
+
+ );
+ },
+ argTypes: {
+ value: {
+ control: false,
+ description: "The selected date value (controlled).",
+ },
+ onChange: {
+ action: "onChange",
+ description: "Callback when date changes. Receives a string or string[].",
+ },
+ footer: {
+ control: false,
+ description:
+ "base-ui useRender slot for the popup footer. Accepts a ReactElement or a render function (props, state) where state is { value, setValue, clear, close }. Replaces the default Today / Tomorrow / Clear actions.",
+ },
+ label: { control: "text", description: "Label for the input field." },
+ placeholder: {
+ control: "text",
+ description: "Placeholder text for the input field.",
+ },
+ },
+};
+
export const DateTime: DateTimePickerStory = {
args: {
...commonArgs,
@@ -142,3 +225,121 @@ export const DateRange: DateRangePickerStory = {
},
},
};
+
+const TODAY = dayjs().format("YYYY-MM-DD");
+
+export const DateRangeWithDisallowAfter: DateRangePickerStory = {
+ args: {
+ ...commonArgs,
+ value: ["", ""],
+ disallowAfter: TODAY,
+ },
+ render: (args) => {
+ const [dateRangeValue, setDateRangeValue] = useState(["", ""]);
+ return (
+
+
+ setDateRangeValue(Array.isArray(val) ? val : [val, ""])
+ }
+ />
+
+ );
+ },
+ argTypes: {
+ value: {
+ control: false,
+ description:
+ "The selected date range value as [start, end] (controlled).",
+ },
+ disallowAfter: {
+ control: "text",
+ description:
+ "Dates strictly after this YYYY-MM-DD value are grayed out and unselectable. This story uses today.",
+ },
+ onChange: {
+ action: "onChange",
+ description:
+ "Callback when date range changes. Receives a string or string[].",
+ },
+ label: { control: "text", description: "Label for the input field." },
+ placeholder: {
+ control: "text",
+ description: "Placeholder text for the input field.",
+ },
+ },
+};
+
+const DATE_RANGE_PRESETS: { label: string; range: [string, string] }[] = [
+ { label: "First Week", range: ["2024-01-01", "2024-01-07"] },
+ { label: "First Month", range: ["2024-01-01", "2024-01-31"] },
+];
+
+export const DateRangeWithFooter: DateRangePickerStory = {
+ args: {
+ ...commonArgs,
+ value: ["", ""],
+ },
+ render: (args) => {
+ const [dateRangeValue, setDateRangeValue] = useState(["", ""]);
+ return (
+
+
+ setDateRangeValue(Array.isArray(val) ? val : [val, ""])
+ }
+ footer={({ setRange, clear, close }) => (
+
+ {DATE_RANGE_PRESETS.map((preset) => (
+ {
+ setRange(preset.range[0], preset.range[1]);
+ close();
+ }}
+ >
+ {preset.label}
+
+ ))}
+
+ Reset
+
+
+ )}
+ />
+
+ );
+ },
+ argTypes: {
+ value: {
+ control: false,
+ description:
+ "The selected date range value as [start, end] (controlled).",
+ },
+ onChange: {
+ action: "onChange",
+ description:
+ "Callback when date range changes. Receives a string or string[].",
+ },
+ footer: {
+ control: false,
+ description:
+ "Render-prop slot for the popup footer. Receives { from, to, setRange, clear, close } so callers can add presets or custom actions.",
+ },
+ label: { control: "text", description: "Label for the input field." },
+ placeholder: {
+ control: "text",
+ description: "Placeholder text for the input field.",
+ },
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/datePicker/datePicker.tsx b/packages/frappe-ui-react/src/components/datePicker/datePicker.tsx
index d2a7d666..d7f9174e 100644
--- a/packages/frappe-ui-react/src/components/datePicker/datePicker.tsx
+++ b/packages/frappe-ui-react/src/components/datePicker/datePicker.tsx
@@ -1,19 +1,49 @@
-import type { DatePickerProps } from "./types";
+import { Popover } from "@base-ui/react/popover";
+import { useRender } from "@base-ui/react/use-render";
+import type {
+ DatePickerProps,
+ DatePickerFooterRender,
+ DatePickerFooterState,
+} from "./types";
import { useDatePicker } from "./useDatePicker";
-import { getDate, getDateValue } from "./utils";
-import { Popover } from "../popover";
+import { getDate, getDateValue, parsePlacement } from "./utils";
import { Button } from "../button";
import { TextInput } from "../textInput";
import FeatherIcon from "../featherIcon";
+import { cn } from "../../utils";
+
+const footerStateAttributesMapping = {
+ value: () => null,
+ setValue: () => null,
+ clear: () => null,
+ close: () => null,
+};
+
+const DatePickerFooter = ({
+ render,
+ state,
+}: {
+ render: DatePickerFooterRender;
+ state: DatePickerFooterState;
+}) => {
+ return useRender({
+ render,
+ state,
+ stateAttributesMapping: footerStateAttributesMapping,
+ });
+};
export const DatePicker: React.FC = ({
value,
placeholder,
+ disabled,
formatter,
placement,
label,
clearable = true,
variant = "subtle",
+ sideOffset = 4,
+ footer,
onChange,
children,
}) => {
@@ -22,6 +52,7 @@ export const DatePicker: React.FC = ({
setOpen,
dateValue,
selectDate,
+ clearValue,
formattedMonth,
datesAsWeeks,
currentMonth,
@@ -48,226 +79,316 @@ export const DatePicker: React.FC = ({
const displayValue =
dateValue && formatter ? formatter(dateValue) : dateValue;
+ const { side, align } = parsePlacement(placement);
+ const openPicker = () => {
+ if (!disabled) {
+ setOpen(true);
+ }
+ };
+ const closePicker = () => setOpen(false);
+ const togglePicker = () => {
+ if (!disabled) {
+ setOpen((prevOpen) => !prevOpen);
+ }
+ };
+ const handleTriggerKeyDown = (
+ event: React.KeyboardEvent
+ ) => {
+ if (disabled) {
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ openPicker();
+ }
+ };
+ const handleChildTriggerKeyDown = (
+ event: React.KeyboardEvent
+ ) => {
+ if (disabled) {
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ openPicker();
+ }
+ };
+ const toggleViewLabel = "Toggle calendar view";
+ const prevLabel =
+ view === "date"
+ ? "Previous month"
+ : view === "month"
+ ? "Previous year"
+ : "Previous years";
+ const nextLabel =
+ view === "date"
+ ? "Next month"
+ : view === "month"
+ ? "Next year"
+ : "Next years";
+
return (
-
- children ? (
- children({ togglePopover, isOpen: open, displayValue })
- ) : (
-
- {label && (
-
{label}
- )}
-
(
-
+
+
+ {children({
+ isOpen: open,
+ displayValue,
+ disabled,
+ togglePopover: togglePicker,
+ openPicker,
+ closePicker,
+ onTriggerKeyDown: handleChildTriggerKeyDown,
+ })}
+
+ ) : (
+
+ {label && (
+ {label}
)}
- variant={variant}
- />
-
- )
- }
- body={({ togglePopover }) => (
-
- {/* Month Switcher */}
-
-
- {view === "date" && formattedMonth}
- {view === "month" && currentYear}
- {view === "year" && `${yearRangeStart} - ${yearRangeStart + 11}`}
-
-
+ (
+
+ )}
+ variant={variant}
+ />
+
+ )
+ }
+ />
+
+
+
+
+ {/* Month Switcher */}
+
- {!clearable && (
+ aria-label={toggleViewLabel}
+ onClick={cycleView}
+ >
+ {view === "date" && formattedMonth}
+ {view === "month" && currentYear}
+ {view === "year" &&
+ `${yearRangeStart} - ${yearRangeStart + 11}`}
+
+
{
- const today = getDate();
- today.setHours(0, 0, 0, 0);
- selectDate(today, true);
- togglePopover();
- }}
+ className="w-7 h-7"
+ icon="chevron-left"
+ aria-label={prevLabel}
+ onClick={prev}
variant="ghost"
- >
- Today
-
- )}
-
+ />
+ {!clearable && (
+ {
+ const today = getDate();
+ today.setHours(0, 0, 0, 0);
+ selectDate(today, true);
+ setOpen(false);
+ }}
+ variant="ghost"
+ >
+ Today
+
+ )}
+
+
-
- {/* Calendar / Month Grid / Year Grid */}
-
- {view === "date" && (
-
-
- {["S", "M", "T", "W", "T", "F", "S"].map((d, i) => (
-
- {d}
+ {/* Calendar / Month Grid / Year Grid */}
+
+ {view === "date" && (
+
+
+ {["S", "M", "T", "W", "T", "F", "S"].map((d, i) => (
+
+ {d}
+
+ ))}
+
+ {datesAsWeeks.map((week, i) => (
+
+ {week.map((date) => {
+ const val = getDateValue(date);
+ const today = getDate();
+ const isToday =
+ date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear() &&
+ date.getMonth() === currentMonth - 1;
+ const isSelected = dateValue === val;
+ const inMonth = date.getMonth() === currentMonth - 1;
+ return (
+ {
+ selectDate(date, true);
+ setOpen(false);
+ }}
+ >
+ {date.getDate()}
+
+ );
+ })}
))}
- {datesAsWeeks.map((week, i) => (
-
- {week.map((date) => {
- const val = getDateValue(date);
- const today = getDate();
- const isToday =
- date.getDate() === today.getDate() &&
- date.getMonth() === today.getMonth() &&
- date.getFullYear() === today.getFullYear() &&
- date.getMonth() === currentMonth - 1;
- const isSelected = dateValue === val;
- const inMonth = date.getMonth() === currentMonth - 1;
- return (
- selectDate(date, true)}
- >
- {date.getDate()}
-
- );
- })}
-
- ))}
-
- )}
+ )}
- {view === "month" && (
-
e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- >
- {months.map((m, i) => {
- const isSelected = i === currentMonth - 1;
- return (
- selectMonth(i)}
- >
- {m.slice(0, 3)}
-
- );
- })}
-
- )}
+ {view === "month" && (
+
+ {months.map((m, i) => {
+ const isSelected = i === currentMonth - 1;
+ return (
+ selectMonth(i)}
+ >
+ {m.slice(0, 3)}
+
+ );
+ })}
+
+ )}
- {view === "year" && (
-
e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- >
- {yearRange.map((y) => {
- const isSelected = y === currentYear;
- return (
- selectYear(y)}
- >
- {y}
-
- );
- })}
-
- )}
-
- {/* Actions */}
- {clearable && (
-
-
-
{
- const today = getDate();
- today.setHours(0, 0, 0, 0);
- selectDate(today, true);
- togglePopover();
- }}
- variant="outline"
+ {view === "year" && (
+
- Today
-
- {
- const today = getDate();
- today.setDate(today.getDate() + 1);
- today.setHours(0, 0, 0, 0);
- selectDate(today, true);
- togglePopover();
+ {yearRange.map((y) => {
+ const isSelected = y === currentYear;
+ return (
+ selectYear(y)}
+ >
+ {y}
+
+ );
+ })}
+
+ )}
+
+ {/* Actions */}
+ {(footer || clearable) && (
+
+
+
+ {
+ const today = getDate();
+ today.setHours(0, 0, 0, 0);
+ selectDate(today, true);
+ setOpen(false);
+ }}
+ variant="outline"
+ >
+ Today
+
+ {
+ const today = getDate();
+ today.setDate(today.getDate() + 1);
+ today.setHours(0, 0, 0, 0);
+ selectDate(today, true);
+ setOpen(false);
+ }}
+ variant="outline"
+ >
+ Tomorrow
+
+
+
+ Clear
+
+
+ )
+ }
+ state={{
+ value: dateValue,
+ setValue: (v) => {
+ const [year, month, day] = v.split("-").map(Number);
+ selectDate(getDate(year, month - 1, day));
+ },
+ clear: clearValue,
+ close: closePicker,
}}
- variant="outline"
- >
- Tomorrow
-
+ />
-
{
- // Clear the value
- onChange?.("");
- setOpen(false);
- }}
- variant="outline"
- >
- Clear
-
-
- )}
-
- )}
- />
+ )}
+
+
+
+
);
};
diff --git a/packages/frappe-ui-react/src/components/datePicker/dateRangePicker.interactions.stories.tsx b/packages/frappe-ui-react/src/components/datePicker/dateRangePicker.interactions.stories.tsx
new file mode 100644
index 00000000..7d2bd0aa
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/datePicker/dateRangePicker.interactions.stories.tsx
@@ -0,0 +1,288 @@
+/**
+ * External dependencies.
+ */
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
+
+/**
+ * Internal dependencies.
+ */
+import { DateRangePicker } from "./index";
+import type { DateRangePickerProps } from "./types";
+
+const meta: Meta
= {
+ title: "Components/DatePicker/Interactions/DateRange",
+ component: DateRangePicker,
+ parameters: {
+ docs: { source: { type: "dynamic" } },
+ layout: "centered",
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+function ControlledDateRangePickerStory(args: DateRangePickerProps) {
+ const [value, setValue] = useState(
+ Array.isArray(args.value) ? args.value : ["", ""]
+ );
+
+ const handleChange = (nextValue: string[]) => {
+ setValue(nextValue);
+ args.onChange?.(nextValue);
+ };
+
+ return (
+
+
+
+ );
+}
+
+export const KeyboardNavigation: Story = {
+ args: {
+ label: "Date range",
+ placeholder: "Select date range",
+ onChange: fn(),
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+
+ input.focus();
+ await userEvent.keyboard("{Enter}");
+
+ const prevButton = await screen.findByRole("button", {
+ name: "Previous month",
+ });
+ const todayButton = await screen.findByRole("button", { name: "Today" });
+ const nextButton = await screen.findByRole("button", {
+ name: "Next month",
+ });
+
+ prevButton.focus();
+ expect(prevButton).toHaveFocus();
+
+ await userEvent.tab();
+ expect(todayButton).toHaveFocus();
+
+ await userEvent.tab();
+ expect(nextButton).toHaveFocus();
+
+ await userEvent.tab();
+ const firstFocusedDate = document.activeElement as HTMLElement;
+ expect(firstFocusedDate).toHaveAttribute("role", "gridcell");
+
+ await userEvent.keyboard("{Enter}");
+
+ // Selecting only the start date emits it with a pending, empty end date.
+ expect(args.onChange).toHaveBeenLastCalledWith([
+ expect.stringMatching(/.+/),
+ "",
+ ]);
+
+ let secondFocusedDate: HTMLElement | null = null;
+ for (let i = 0; i < 6; i++) {
+ await userEvent.tab();
+ const activeElement = document.activeElement as HTMLElement;
+ if (activeElement?.getAttribute("role") === "gridcell") {
+ secondFocusedDate = activeElement;
+ break;
+ }
+ }
+
+ expect(secondFocusedDate).not.toBeNull();
+ expect(secondFocusedDate).toHaveAttribute("role", "gridcell");
+
+ await userEvent.keyboard("{Enter}");
+
+ await waitFor(() => {
+ expect((input as HTMLInputElement).value).toContain(" to ");
+ expect(args.onChange).toHaveBeenCalled();
+ });
+ },
+};
+
+export const QuickActions: Story = {
+ args: {
+ label: "Date range",
+ placeholder: "Select date range",
+ onChange: fn(),
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+ const today = new Date();
+ const formattedToday = `${today.getFullYear()}-${String(
+ today.getMonth() + 1
+ ).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
+ const monthLabel = `${today.toLocaleString("en-US", {
+ month: "short",
+ })} ${today.getFullYear()}`;
+
+ await userEvent.click(input);
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Previous month" })
+ );
+
+ const previousMonthDate = screen
+ .getAllByRole("gridcell")
+ .find((gridCell) => gridCell.className.includes("text-ink-gray-8"));
+ expect(previousMonthDate).toBeDefined();
+ await userEvent.click(previousMonthDate as HTMLElement);
+
+ await userEvent.click(await screen.findByRole("button", { name: "Today" }));
+
+ await waitFor(() => {
+ expect(input).toHaveValue(`${formattedToday} to ${formattedToday}`);
+ expect(args.onChange).toHaveBeenLastCalledWith([
+ formattedToday,
+ formattedToday,
+ ]);
+ });
+
+ await userEvent.click(input);
+ const toggleViewButton = await screen.findByRole("button", {
+ name: "Toggle calendar view",
+ });
+ expect(toggleViewButton).toHaveTextContent(monthLabel);
+
+ const selectedToday = screen
+ .getAllByRole("gridcell")
+ .find((gridCell) => gridCell.getAttribute("aria-pressed") === "true");
+ expect(selectedToday).toHaveTextContent(String(today.getDate()));
+
+ await userEvent.click(await screen.findByRole("button", { name: "Clear" }));
+
+ await waitFor(() => {
+ expect(input).toHaveValue("");
+ expect(args.onChange).toHaveBeenLastCalledWith(["", ""]);
+ });
+
+ await userEvent.click(input);
+ const resetToggleButton = await screen.findByRole("button", {
+ name: "Toggle calendar view",
+ });
+ expect(resetToggleButton).toHaveTextContent(monthLabel);
+ },
+};
+
+export const DisallowAfter: Story = {
+ args: {
+ label: "Date range",
+ placeholder: "Select date range",
+ value: ["2024-01-10", "2024-01-12"],
+ disallowAfter: "2024-01-15",
+ onChange: fn(),
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+
+ await userEvent.click(input);
+ await screen.findByRole("grid", { name: "Calendar dates" });
+
+ const findGridCell = (day: string) =>
+ screen
+ .getAllByRole("gridcell")
+ .find((cell) => cell.textContent === day) as HTMLElement;
+
+ // Dates after the disallowAfter boundary are grayed out and unselectable.
+ const disallowedDate = findGridCell("20");
+ expect(disallowedDate).toBeDisabled();
+ expect(disallowedDate.className).toContain("cursor-not-allowed");
+ expect(disallowedDate.className).toContain("text-ink-gray-3");
+
+ await userEvent.click(disallowedDate);
+ expect(args.onChange).not.toHaveBeenCalled();
+
+ // The Today shortcut obeys the same boundary as the calendar cells.
+ const todayButton = screen.getByRole("button", { name: "Today" });
+ expect(todayButton).toBeDisabled();
+
+ await userEvent.click(todayButton, { pointerEventsCheck: 0 });
+ expect(args.onChange).not.toHaveBeenCalled();
+
+ // The boundary date itself stays selectable.
+ const boundaryDate = findGridCell("15");
+ expect(boundaryDate).toBeEnabled();
+
+ await userEvent.click(findGridCell("14"));
+ expect(args.onChange).toHaveBeenLastCalledWith(["2024-01-14", ""]);
+
+ await userEvent.click(boundaryDate);
+
+ await waitFor(() => {
+ expect(input).toHaveValue("2024-01-14 to 2024-01-15");
+ expect(args.onChange).toHaveBeenLastCalledWith([
+ "2024-01-14",
+ "2024-01-15",
+ ]);
+ });
+ },
+};
+
+export const FooterSlot: Story = {
+ args: {
+ label: "Date range",
+ placeholder: "Select date range",
+ onChange: fn(),
+ footer: ({ setRange, clear, close }) => (
+
+ {
+ setRange("2024-01-01", "2024-01-07");
+ close();
+ }}
+ >
+ First Week
+
+
+ Reset
+
+
+ ),
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+
+ // Applying a preset from the footer fills the range and closes the popup.
+ await userEvent.click(input);
+ await userEvent.click(
+ await screen.findByRole("button", { name: "First Week" })
+ );
+
+ await waitFor(() => {
+ expect(input).toHaveValue("2024-01-01 to 2024-01-07");
+ expect(args.onChange).toHaveBeenLastCalledWith([
+ "2024-01-01",
+ "2024-01-07",
+ ]);
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole("button", { name: "First Week" })
+ ).not.toBeInTheDocument();
+ });
+
+ // Clearing from the footer resets the value but keeps the popup open.
+ await userEvent.click(input);
+ await userEvent.click(await screen.findByRole("button", { name: "Reset" }));
+
+ await waitFor(() => {
+ expect(input).toHaveValue("");
+ expect(args.onChange).toHaveBeenLastCalledWith(["", ""]);
+ });
+
+ expect(screen.getByRole("button", { name: "Reset" })).toBeInTheDocument();
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/datePicker/dateRangePicker.tsx b/packages/frappe-ui-react/src/components/datePicker/dateRangePicker.tsx
index 0ea7e98c..7cb21b1b 100644
--- a/packages/frappe-ui-react/src/components/datePicker/dateRangePicker.tsx
+++ b/packages/frappe-ui-react/src/components/datePicker/dateRangePicker.tsx
@@ -1,140 +1,30 @@
-import { useState } from "react";
+/**
+ * External dependencies.
+ */
+import { useCallback } from "react";
+import { Popover } from "@base-ui/react/popover";
+/**
+ * Internal dependencies.
+ */
import type { DateRangePickerProps } from "./types";
-import { useDatePicker } from "./useDatePicker";
-import { getDate, getDateValue } from "./utils";
-import { Popover } from "../popover";
+import { useDateRangePicker } from "./useDateRangePicker";
+import { getDate, getDateValue, parsePlacement } from "./utils";
import { Button } from "../button";
import { TextInput } from "../textInput";
import FeatherIcon from "../featherIcon";
-
-function useDateRangePicker({
- value,
- onChange,
-}: {
- value?: string[];
- onChange?: (v: string[]) => void;
-}) {
- // Internal selection state
- const [fromDate, setFromDate] = useState(value?.[0] || "");
- const [toDate, setToDate] = useState(value?.[1] || "");
-
- const {
- open,
- setOpen,
- formattedMonth,
- datesAsWeeks,
- currentMonth,
- currentYear,
- view,
- cycleView,
- selectMonth,
- selectYear,
- yearRangeStart,
- yearRange,
- prev,
- next,
- resetView,
- months,
- today,
- } = useDatePicker({
- value: fromDate,
- onChange: () => {},
- });
-
- function handleDateClick(date: Date): boolean {
- // Zero out time for date
- const d = new Date(date);
- d.setHours(0, 0, 0, 0);
- const v = getDateValue(d);
- if (fromDate && toDate) {
- setFromDate(v);
- setToDate("");
- } else if (fromDate && !toDate) {
- setToDate(v);
- swapDatesIfNecessary(fromDate, v);
- onChange?.([fromDate, v]);
- return true;
- } else {
- setFromDate(v);
- }
- return false;
- }
-
- function swapDatesIfNecessary(a: string, b: string) {
- if (!a || !b) return;
- const from = getDate(a);
- from.setHours(0, 0, 0, 0);
- const to = getDate(b);
- to.setHours(0, 0, 0, 0);
-
- if (from > to) {
- setFromDate(getDateValue(to));
- setToDate(getDateValue(from));
- }
- }
-
- function handleToday() {
- const d = new Date(today);
- d.setHours(0, 0, 0, 0);
- const todayStr = getDateValue(d);
- setFromDate(todayStr);
- setToDate(todayStr);
- onChange?.([todayStr, todayStr]);
- }
-
- function clearDates() {
- setFromDate("");
- setToDate("");
- onChange?.(["", ""]);
- }
-
- function selectDates() {
- onChange?.([fromDate, toDate]);
- setOpen(false);
- }
-
- function isInRange(date: Date) {
- if (!fromDate || !toDate) return false;
- return date >= getDate(fromDate) && date <= getDate(toDate);
- }
-
- return {
- open,
- setOpen,
- fromDate,
- setFromDate,
- toDate,
- setToDate,
- formattedMonth,
- datesAsWeeks,
- currentMonth,
- currentYear,
- view,
- cycleView,
- selectMonth,
- selectYear,
- yearRangeStart,
- yearRange,
- prev,
- next,
- resetView,
- months,
- today,
- handleToday,
- handleDateClick,
- clearDates,
- selectDates,
- isInRange,
- };
-}
+import { cn } from "../../utils";
export const DateRangePicker: React.FC = ({
value,
placeholder,
+ disabled,
+ disallowAfter,
formatter,
placement,
+ sideOffset = 4,
label,
+ footer,
onChange,
children,
}) => {
@@ -160,236 +50,340 @@ export const DateRangePicker: React.FC = ({
handleToday,
handleDateClick,
clearDates,
+ applyRange,
isInRange,
+ isDateDisallowed,
+ isTodayDisallowed,
} = useDateRangePicker({
value: Array.isArray(value) ? value : undefined,
onChange,
+ disallowAfter,
});
- const handleOpenChange = (isOpen: boolean) => {
- setOpen(isOpen);
- if (!isOpen) {
- resetView();
+ const handleOpenChange = useCallback(
+ (isOpen: boolean) => {
+ setOpen(isOpen);
+ if (!isOpen) {
+ resetView();
+ }
+ },
+ [resetView, setOpen]
+ );
+
+ const openPicker = useCallback(() => {
+ if (!disabled) {
+ setOpen(true);
}
- };
+ }, [disabled, setOpen]);
- return (
- {
- const from = fromDate ? fromDate.slice(0, 10) : "";
- const to = toDate ? toDate.slice(0, 10) : "";
- const displayValue = formatter
- ? formatter(from, to)
- : from && to
- ? `${from} to ${to}`
- : from || "";
+ const closePicker = useCallback(() => setOpen(false), [setOpen]);
- if (children) {
- return children({ togglePopover, isOpen: open, displayValue });
- }
+ const togglePicker = useCallback(() => {
+ if (!disabled) {
+ setOpen((prevOpen) => !prevOpen);
+ }
+ }, [disabled, setOpen]);
- return (
-
- {label && (
-
{label}
- )}
-
(
-
+ const handleTriggerKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ openPicker();
+ }
+ },
+ [disabled, openPicker]
+ );
+
+ const { side, align } = parsePlacement(placement);
+
+ const from = fromDate ? fromDate.slice(0, 10) : "";
+ const to = toDate ? toDate.slice(0, 10) : "";
+ const displayValue = formatter
+ ? formatter(from, to)
+ : from && to
+ ? `${from} to ${to}`
+ : from || "";
+ const toggleViewLabel = "Toggle calendar view";
+ const prevLabel =
+ view === "date"
+ ? "Previous month"
+ : view === "month"
+ ? "Previous year"
+ : "Previous years";
+ const nextLabel =
+ view === "date"
+ ? "Next month"
+ : view === "month"
+ ? "Next year"
+ : "Next years";
+
+ return (
+
+
+ {children({
+ isOpen: open,
+ displayValue,
+ disabled,
+ togglePopover: togglePicker,
+ openPicker,
+ closePicker,
+ onTriggerKeyDown: handleTriggerKeyDown,
+ })}
+
+ ) : (
+
+ {label && (
+ {label}
)}
- />
-
- );
- }}
- body={({ togglePopover }) => (
-
- {/* Month Switcher */}
-
-
- {view === "date" && formattedMonth}
- {view === "month" && currentYear}
- {view === "year" && `${yearRangeStart} - ${yearRangeStart + 11}`}
-
-
- (
+
+ )}
/>
+
+ )
+ }
+ />
+
+
+
+
+ {/* Month Switcher */}
+
{
- handleToday();
- togglePopover();
- }}
+ aria-label={toggleViewLabel}
+ onClick={cycleView}
>
- Today
+ {view === "date" && formattedMonth}
+ {view === "month" && currentYear}
+ {view === "year" &&
+ `${yearRangeStart} - ${yearRangeStart + 11}`}
-
+
+
+ {
+ if (handleToday()) {
+ setOpen(false);
+ }
+ }}
+ >
+ Today
+
+
+
-
- {/* Calendar / Month Grid / Year Grid */}
-
- {view === "date" && (
-
-
- {["S", "M", "T", "W", "T", "F", "S"].map((d, i) => (
-
- {d}
+ {/* Calendar / Month Grid / Year Grid */}
+
+ {view === "date" && (
+
+
+ {["S", "M", "T", "W", "T", "F", "S"].map((d, i) => (
+
+ {d}
+
+ ))}
+
+ {datesAsWeeks.map((week, i) => (
+
+ {week.map((date) => {
+ const val = getDateValue(date);
+ const today = getDate();
+ const isToday =
+ date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear() &&
+ date.getMonth() === currentMonth - 1;
+ const isToDate =
+ toDate && getDateValue(date) === toDate;
+ const isFromDate =
+ fromDate && getDateValue(date) === fromDate;
+ const isDisallowed = isDateDisallowed(date);
+
+ return (
+ {
+ if (handleDateClick(date)) {
+ setOpen(false);
+ }
+ }}
+ >
+ {date.getDate()}
+
+ );
+ })}
))}
- {datesAsWeeks.map((week, i) => (
-
- {week.map((date) => {
- const val = getDateValue(date);
- const today = getDate();
- const isToday =
- date.getDate() === today.getDate() &&
- date.getMonth() === today.getMonth() &&
- date.getFullYear() === today.getFullYear() &&
- date.getMonth() === currentMonth - 1;
- const isToDate = toDate && getDateValue(date) === toDate;
- const isFromDate =
- fromDate && getDateValue(date) === fromDate;
+ )}
- return (
-
- handleDateClick(date) && togglePopover()
- }
- >
- {date.getDate()}
-
- );
- })}
-
- ))}
-
- )}
+ {view === "month" && (
+
+ {months.map((m, i) => {
+ const isSelected = i === currentMonth - 1;
+ return (
+ selectMonth(i)}
+ >
+ {m.slice(0, 3)}
+
+ );
+ })}
+
+ )}
- {view === "month" && (
-
e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- >
- {months.map((m, i) => {
- const isSelected = i === currentMonth - 1;
- return (
- selectMonth(i)}
- >
- {m.slice(0, 3)}
-
- );
- })}
-
- )}
+ {view === "year" && (
+
+ {yearRange.map((y) => {
+ const isSelected = y === currentYear;
+ return (
+ selectYear(y)}
+ >
+ {y}
+
+ );
+ })}
+
+ )}
+
- {view === "year" && (
-
e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- >
- {yearRange.map((y) => {
- const isSelected = y === currentYear;
- return (
-
selectYear(y)}
- >
- {y}
-
- );
+ {/* Actions */}
+ {footer ? (
+
+ {footer({
+ from: fromDate,
+ to: toDate,
+ setRange: applyRange,
+ clear: clearDates,
+ close: () => setOpen(false),
})}
+ ) : (
+ fromDate &&
+ toDate && (
+
+ {
+ clearDates();
+ setOpen(false);
+ }}
+ variant="outline"
+ >
+ Clear
+
+
+ )
)}
-
-
- {/* Actions */}
- {fromDate && toDate && (
-
- {
- clearDates();
- togglePopover();
- }}
- variant="outline"
- >
- Clear
-
-
- )}
-
- )}
- />
+
+
+
+
);
};
diff --git a/packages/frappe-ui-react/src/components/datePicker/dateTimePicker.interactions.stories.tsx b/packages/frappe-ui-react/src/components/datePicker/dateTimePicker.interactions.stories.tsx
new file mode 100644
index 00000000..271c9cec
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/datePicker/dateTimePicker.interactions.stories.tsx
@@ -0,0 +1,161 @@
+/**
+ * External dependencies.
+ */
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
+
+/**
+ * Internal dependencies.
+ */
+import { DateTimePicker } from "./index";
+import type { DateTimePickerProps } from "./types";
+
+const meta: Meta
= {
+ title: "Components/DatePicker/Interactions/DateTime",
+ component: DateTimePicker,
+ parameters: {
+ docs: { source: { type: "dynamic" } },
+ layout: "centered",
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+function ControlledDateTimePickerStory(args: DateTimePickerProps) {
+ const [value, setValue] = useState(args.value || "");
+
+ const handleChange = (nextValue: string) => {
+ setValue(nextValue);
+ args.onChange?.(nextValue);
+ };
+
+ return (
+
+
+
+ );
+}
+
+export const KeyboardNavigation: Story = {
+ args: {
+ label: "Date & time",
+ placeholder: "Select date and time",
+ clearable: false,
+ onChange: fn(),
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+
+ input.focus();
+ await userEvent.keyboard("{Enter}");
+
+ const prevButton = await screen.findByRole("button", {
+ name: "Previous month",
+ });
+ const nowButton = await screen.findByRole("button", { name: "Now" });
+ const nextButton = await screen.findByRole("button", {
+ name: "Next month",
+ });
+
+ prevButton.focus();
+ expect(prevButton).toHaveFocus();
+
+ await userEvent.tab();
+ expect(nowButton).toHaveFocus();
+
+ await userEvent.tab();
+ expect(nextButton).toHaveFocus();
+
+ await userEvent.tab();
+ const focusedDate = document.activeElement as HTMLElement;
+ expect(focusedDate).toHaveAttribute("role", "gridcell");
+
+ await userEvent.keyboard("{Enter}");
+
+ await waitFor(() => {
+ expect((input as HTMLInputElement).value).not.toBe("");
+ expect(args.onChange).toHaveBeenCalled();
+ });
+ },
+};
+
+export const QuickActions: Story = {
+ args: {
+ label: "Date & time",
+ placeholder: "Select date and time",
+ onChange: fn(),
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+ const today = new Date();
+ const tomorrow = new Date(today);
+ const formattedToday = `${today.getFullYear()}-${String(
+ today.getMonth() + 1
+ ).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const formattedTomorrow = `${tomorrow.getFullYear()}-${String(
+ tomorrow.getMonth() + 1
+ ).padStart(2, "0")}-${String(tomorrow.getDate()).padStart(2, "0")}`;
+ const monthLabel = `${today.toLocaleString("en-US", {
+ month: "short",
+ })} ${today.getFullYear()}`;
+
+ await userEvent.click(input);
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Previous month" })
+ );
+
+ const previousMonthDate = screen
+ .getAllByRole("gridcell")
+ .find((gridCell) => gridCell.className.includes("text-ink-gray-8"));
+ expect(previousMonthDate).toBeDefined();
+ await userEvent.click(previousMonthDate as HTMLElement);
+
+ await userEvent.click(input);
+ await userEvent.click(await screen.findByRole("button", { name: "Now" }));
+
+ await waitFor(() => {
+ expect((input as HTMLInputElement).value).toContain(formattedToday);
+ expect(args.onChange).toHaveBeenCalled();
+ });
+
+ await userEvent.click(input);
+ const toggleViewButton = await screen.findByRole("button", {
+ name: "Toggle calendar view",
+ });
+ expect(toggleViewButton).toHaveTextContent(monthLabel);
+
+ const selectedToday = screen
+ .getAllByRole("gridcell")
+ .find((gridCell) => gridCell.getAttribute("aria-selected") === "true");
+ expect(selectedToday).toHaveTextContent(String(today.getDate()));
+
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Tomorrow" })
+ );
+
+ await waitFor(() => {
+ expect((input as HTMLInputElement).value).toContain(formattedTomorrow);
+ });
+
+ await userEvent.click(await screen.findByRole("button", { name: "Clear" }));
+
+ await waitFor(() => {
+ expect((input as HTMLInputElement).value).toBe("");
+ expect(args.onChange).toHaveBeenLastCalledWith("");
+ });
+
+ await userEvent.click(input);
+ const resetToggleButton = await screen.findByRole("button", {
+ name: "Toggle calendar view",
+ });
+ expect(resetToggleButton).toHaveTextContent(monthLabel);
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/datePicker/dateTimePicker.tsx b/packages/frappe-ui-react/src/components/datePicker/dateTimePicker.tsx
index 0a5d7ebb..34c31a5c 100644
--- a/packages/frappe-ui-react/src/components/datePicker/dateTimePicker.tsx
+++ b/packages/frappe-ui-react/src/components/datePicker/dateTimePicker.tsx
@@ -1,15 +1,17 @@
import { useCallback, useMemo, useState } from "react";
+import { Popover } from "@base-ui/react/popover";
import type { DateTimePickerProps } from "./types";
import { useDatePicker } from "./useDatePicker";
-import { getDate, getDateValue } from "./utils";
-import { Popover } from "../popover";
+import { getDate, getDateValue, parsePlacement } from "./utils";
import { Button } from "../button";
import { TextInput } from "../textInput";
import FeatherIcon from "../featherIcon";
+import { cn } from "../../utils";
export const DateTimePicker: React.FC = ({
value,
placeholder,
+ disabled,
formatter,
placement,
label,
@@ -74,259 +76,347 @@ export const DateTimePicker: React.FC = ({
? formatter(displayValue)
: displayValue;
+ const { side, align } = parsePlacement(placement);
+ const openPicker = () => {
+ if (!disabled) {
+ setOpen(true);
+ }
+ };
+ const closePicker = () => setOpen(false);
+ const togglePicker = () => {
+ if (!disabled) {
+ setOpen((prevOpen) => !prevOpen);
+ }
+ };
+ const handleTriggerKeyDown = (
+ event: React.KeyboardEvent
+ ) => {
+ if (disabled) {
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ openPicker();
+ }
+ };
+ const handleChildTriggerKeyDown = (
+ event: React.KeyboardEvent
+ ) => {
+ if (disabled) {
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ openPicker();
+ }
+ };
+ const handleTimeTriggerKeyDown = (
+ event: React.KeyboardEvent
+ ) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setTimeDropdownOpen(true);
+ }
+ };
+ const toggleViewLabel = "Toggle calendar view";
+ const prevLabel =
+ view === "date"
+ ? "Previous month"
+ : view === "month"
+ ? "Previous year"
+ : "Previous years";
+ const nextLabel =
+ view === "date"
+ ? "Next month"
+ : view === "month"
+ ? "Next year"
+ : "Next years";
+
return (
-
- children ? (
- children({
- togglePopover,
- isOpen: open,
- displayValue: formattedDisplayValue,
- })
- ) : (
-
- {label && (
-
{label}
- )}
-
(
-
+
+
+ {children({
+ isOpen: open,
+ displayValue: formattedDisplayValue,
+ disabled,
+ togglePopover: togglePicker,
+ openPicker,
+ closePicker,
+ onTriggerKeyDown: handleChildTriggerKeyDown,
+ })}
+
+ ) : (
+
+ {label && (
+ {label}
)}
- />
-
- )
- }
- body={() => (
-
- {/* Header (Month/Year navigation) */}
-
-
- {view === "date" && formattedMonth}
- {view === "month" && currentYear}
- {view === "year" && `${yearRangeStart} - ${yearRangeStart + 11}`}
-
-
- (
+
+ )}
/>
- {!clearable && (
-
- Now
-
- )}
+
+ )
+ }
+ />
+
+
+
+
+ {/* Header (Month/Year navigation) */}
+
+ aria-label={toggleViewLabel}
+ onClick={cycleView}
+ >
+ {view === "date" && formattedMonth}
+ {view === "month" && currentYear}
+ {view === "year" &&
+ `${yearRangeStart} - ${yearRangeStart + 11}`}
+
+
+
+ {!clearable && (
+
+ Now
+
+ )}
+
+
-
- {/* Calendar / Month Grid / Year Grid */}
-
- {view === "date" && (
-
-
- {["S", "M", "T", "W", "T", "F", "S"].map((d, i) => (
-
- {d}
+ {/* Calendar / Month Grid / Year Grid */}
+
+ {view === "date" && (
+
+
+ {["S", "M", "T", "W", "T", "F", "S"].map((d, i) => (
+
+ {d}
+
+ ))}
+
+ {datesAsWeeks.map((week, i) => (
+
+ {week.map((date) => {
+ const val = getDateValue(date);
+ const today = getDate();
+ const isToday =
+ date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear() &&
+ date.getMonth() === currentMonth - 1;
+ const isSelected = dateValue === val;
+ const inMonth = date.getMonth() === currentMonth - 1;
+ return (
+ selectDate(date, true)}
+ >
+ {date.getDate()}
+
+ );
+ })}
))}
- {datesAsWeeks.map((week, i) => (
-
- {week.map((date) => {
- const val = getDateValue(date);
- const today = getDate();
- const isToday =
- date.getDate() === today.getDate() &&
- date.getMonth() === today.getMonth() &&
- date.getFullYear() === today.getFullYear() &&
- date.getMonth() === currentMonth - 1;
- const isSelected = dateValue === val;
- const inMonth = date.getMonth() === currentMonth - 1;
- return (
- selectDate(date, true)}
- >
- {date.getDate()}
-
- );
- })}
-
- ))}
-
- )}
-
- {view === "month" && (
-
e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- >
- {months.map((m, i) => {
- const isSelected = i === currentMonth - 1;
- return (
- selectMonth(i)}
- >
- {m.slice(0, 3)}
-
- );
- })}
-
- )}
-
- {view === "year" && (
-
e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- >
- {yearRange.map((y) => {
- const isSelected = y === currentYear;
- return (
- selectYear(y)}
- >
- {y}
-
- );
- })}
-
- )}
-
-
- {/* Time Picker Section */}
-
e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- >
-
(
- (
-
- )}
- />
)}
- body={() => (
+
+ {view === "month" && (
e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
+ className="grid grid-cols-3 gap-1"
+ role="grid"
+ aria-label="Select month"
>
- {timeOptions.map((time) => {
- const isSelected = timeValue === time;
+ {months.map((m, i) => {
+ const isSelected = i === currentMonth - 1;
return (
- {
- selectTime(time);
- setTimeDropdownOpen(false);
- }}
+ aria-selected={isSelected}
+ onClick={() => selectMonth(i)}
>
- {time}
-
+ {m.slice(0, 3)}
+
);
})}
)}
- />
-
- {/* Footer Actions */}
- {clearable && (
-
-
-
- Now
-
-
- Tomorrow
-
-
- {(dateValue || timeValue) && (
-
- Clear
-
+ {view === "year" && (
+
+ {yearRange.map((y) => {
+ const isSelected = y === currentYear;
+ return (
+ selectYear(y)}
+ >
+ {y}
+
+ );
+ })}
+
)}
- )}
-
- )}
- />
+
+ {/* Time Picker Section */}
+
+ }
+ />
+
+
+
+ {timeOptions.map((time) => {
+ const isSelected = timeValue === time;
+ return (
+ {
+ selectTime(time);
+ setTimeDropdownOpen(false);
+ }}
+ >
+ {time}
+
+ );
+ })}
+
+
+
+
+
+
+ {/* Footer Actions */}
+ {clearable && (
+
+
+
+ Now
+
+
+ Tomorrow
+
+
+ {(dateValue || timeValue) && (
+
+ Clear
+
+ )}
+
+ )}
+
+
+
+
);
};
diff --git a/packages/frappe-ui-react/src/components/datePicker/types.ts b/packages/frappe-ui-react/src/components/datePicker/types.ts
index 8b0d9435..491b97d4 100644
--- a/packages/frappe-ui-react/src/components/datePicker/types.ts
+++ b/packages/frappe-ui-react/src/components/datePicker/types.ts
@@ -1,13 +1,27 @@
+import type { ComponentProps, KeyboardEventHandler } from "react";
+import { Popover } from "@base-ui/react/popover";
+import type { useRender } from "@base-ui/react/use-render";
+
+type PopoverSideOffset = ComponentProps<
+ typeof Popover.Positioner
+>["sideOffset"];
+
export interface DatePickerChildrenProps {
- togglePopover: () => void;
isOpen: boolean;
displayValue: string;
+ disabled?: boolean;
+ togglePopover: () => void;
+ openPicker: () => void;
+ closePicker: () => void;
+ onTriggerKeyDown: KeyboardEventHandler
;
}
export interface DatePickerProps {
value?: string | string[];
modelValue?: string | string[];
placeholder?: string;
+ disabled?: boolean;
+ sideOffset?: PopoverSideOffset;
formatter?: (date: string) => string;
readonly?: boolean;
inputClass?: string;
@@ -27,13 +41,28 @@ export interface DatePickerProps {
| "right-end";
label?: string;
clearable?: boolean;
+ footer?: DatePickerFooterRender;
onChange?: (value: string | string[]) => void;
children?: (props: DatePickerChildrenProps) => React.ReactNode;
}
+export type DatePickerFooterState = {
+ value: string;
+ // Apply a yyyy-mm-dd value (syncs the calendar and fires onChange).
+ setValue: (value: string) => void;
+ // Reset the current selection and close the popup.
+ clear: () => void;
+ // Close the popup.
+ close: () => void;
+};
+
+export type DatePickerFooterRender =
+ useRender.RenderProp;
+
export interface DateTimePickerProps {
value?: string;
placeholder?: string;
+ disabled?: boolean;
formatter?: (date: string) => string;
placement?:
| "top-start"
@@ -57,6 +86,10 @@ export interface DateTimePickerProps {
export interface DateRangePickerProps {
value?: string[];
placeholder?: string;
+ disabled?: boolean;
+ // Dates strictly after this YYYY-MM-DD value are grayed out and unselectable.
+ disallowAfter?: string;
+ sideOffset?: PopoverSideOffset;
formatter?: (from: string, to: string) => string;
placement?:
| "top-start"
@@ -72,13 +105,29 @@ export interface DateRangePickerProps {
| "right"
| "right-end";
label?: string;
+ footer?: (props: DateRangeFooterProps) => React.ReactNode;
onChange?: (value: string[]) => void;
children?: (props: DatePickerChildrenProps) => React.ReactNode;
}
+export interface DateRangeFooterProps {
+ from: string;
+ to: string;
+ // Apply a [from, to] selection (syncs the calendar and fires onChange).
+ setRange: (from: string, to: string) => void;
+ // Reset the current selection (keeps the popup open).
+ clear: () => void;
+ // Close the popup.
+ close: () => void;
+}
+
export type DatePickerEmits = {
(event: "update:modelValue", value: string): void;
(event: "change", value: string): void;
};
export type DatePickerViewMode = "date" | "month" | "year";
+
+// Popover placement types
+export type PopoverSide = "top" | "bottom" | "left" | "right";
+export type PopoverAlign = "start" | "center" | "end";
diff --git a/packages/frappe-ui-react/src/components/datePicker/useDatePicker.ts b/packages/frappe-ui-react/src/components/datePicker/useDatePicker.ts
index b2a535aa..172c587f 100644
--- a/packages/frappe-ui-react/src/components/datePicker/useDatePicker.ts
+++ b/packages/frappe-ui-react/src/components/datePicker/useDatePicker.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getDate,
getDatesAfter,
@@ -71,6 +71,29 @@ export function useDatePicker({
const [currentMonth, setCurrentMonth] = useState(
today.getMonth() + 1
);
+ const selectedDateValueRef = useRef("");
+
+ const resetCalendarToToday = useCallback(() => {
+ setCurrentYear(today.getFullYear());
+ setCurrentMonth(today.getMonth() + 1);
+ }, [today]);
+
+ const syncCalendarToDate = useCallback((date: Date) => {
+ setCurrentYear(date.getFullYear());
+ setCurrentMonth(date.getMonth() + 1);
+ }, []);
+
+ const syncCalendarToValue = useCallback(
+ (nextDateValue: string) => {
+ selectedDateValueRef.current = nextDateValue;
+ if (!nextDateValue) return;
+ const selectedDate = getDate(nextDateValue);
+ if (!isNaN(selectedDate.getTime())) {
+ syncCalendarToDate(selectedDate);
+ }
+ },
+ [syncCalendarToDate]
+ );
useEffect(() => {
if (typeof value === "string" && value) {
@@ -78,6 +101,8 @@ export function useDatePicker({
const parts = value.split(" ");
const datePart = parts[0] || "";
setDateValue(datePart);
+ selectedDateValueRef.current = datePart;
+ syncCalendarToValue(datePart);
if (parts.length >= 2) {
const timePart = parts.slice(1).join(" ");
@@ -90,12 +115,15 @@ export function useDatePicker({
}
} else {
setDateValue(value);
+ selectedDateValueRef.current = value;
+ syncCalendarToValue(value);
}
} else {
setDateValue("");
setTimeValue("");
+ selectedDateValueRef.current = "";
}
- }, [value, withTime]);
+ }, [value, withTime, syncCalendarToValue]);
const dates = useMemo(() => {
if (!(currentYear && currentMonth)) return [];
@@ -160,7 +188,9 @@ export function useDatePicker({
const selectDate = useCallback(
(d: Date, close = false) => {
const v = getDateValue(d);
+ selectedDateValueRef.current = v;
setDateValue(v);
+ syncCalendarToDate(d);
if (withTime) {
const { hours, minutes } = parseTimeValue(timeValue);
const newDate = getDate(v);
@@ -171,7 +201,7 @@ export function useDatePicker({
}
if (close) setOpen(false);
},
- [onChange, withTime, timeValue]
+ [onChange, withTime, timeValue, syncCalendarToDate]
);
const selectToday = useCallback(() => {
@@ -196,7 +226,9 @@ export function useDatePicker({
const selectNow = useCallback(() => {
const now = getDate();
const v = getDateValue(now);
+ selectedDateValueRef.current = v;
setDateValue(v);
+ syncCalendarToDate(now);
if (withTime) {
const time = formatTime12h(now.getHours(), now.getMinutes());
setTimeValue(time);
@@ -205,13 +237,15 @@ export function useDatePicker({
onChange?.(v);
}
setOpen(false);
- }, [onChange, withTime]);
+ }, [onChange, withTime, syncCalendarToDate]);
const selectTomorrow = useCallback(() => {
const tomorrow = getDate();
tomorrow.setDate(tomorrow.getDate() + 1);
const v = getDateValue(tomorrow);
+ selectedDateValueRef.current = v;
setDateValue(v);
+ syncCalendarToDate(tomorrow);
if (withTime) {
const time =
@@ -225,14 +259,16 @@ export function useDatePicker({
tomorrow.setHours(0, 0, 0, 0);
onChange?.(v);
}
- }, [onChange, withTime, timeValue]);
+ }, [onChange, withTime, timeValue, syncCalendarToDate]);
const clearValue = useCallback(() => {
+ selectedDateValueRef.current = "";
setDateValue("");
setTimeValue("");
+ resetCalendarToToday();
onChange?.("");
setOpen(false);
- }, [onChange]);
+ }, [onChange, resetCalendarToToday]);
const displayValue = useMemo(() => {
if (!dateValue) return "";
@@ -307,14 +343,12 @@ export function useDatePicker({
const resetView = useCallback(() => {
setView("date");
- if (dateValue) {
- const selectedDate = getDate(dateValue);
- if (!isNaN(selectedDate.getTime())) {
- setCurrentYear(selectedDate.getFullYear());
- setCurrentMonth(selectedDate.getMonth() + 1);
- }
+ if (selectedDateValueRef.current) {
+ syncCalendarToValue(selectedDateValueRef.current);
+ return;
}
- }, [dateValue]);
+ resetCalendarToToday();
+ }, [syncCalendarToValue, resetCalendarToToday]);
return {
open,
@@ -343,6 +377,9 @@ export function useDatePicker({
prev,
next,
resetView,
+ resetCalendarToToday,
+ syncCalendarToDate,
+ syncCalendarToValue,
months: MONTHS,
timeValue,
setTimeValue,
diff --git a/packages/frappe-ui-react/src/components/datePicker/useDateRangePicker.ts b/packages/frappe-ui-react/src/components/datePicker/useDateRangePicker.ts
new file mode 100644
index 00000000..86fef8bf
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/datePicker/useDateRangePicker.ts
@@ -0,0 +1,166 @@
+/**
+ * External dependencies.
+ */
+import { useCallback, useState } from "react";
+
+/**
+ * Internal dependencies.
+ */
+import { useDatePicker } from "./useDatePicker";
+import { getDate, getDateValue } from "./utils";
+
+export function useDateRangePicker({
+ value,
+ onChange,
+ disallowAfter,
+}: {
+ value?: string[];
+ onChange?: (v: string[]) => void;
+ disallowAfter?: string;
+}) {
+ // Selection state, used only when the range is not driven by `value`.
+ const [internalFrom, setInternalFrom] = useState(value?.[0] || "");
+ const [internalTo, setInternalTo] = useState(value?.[1] || "");
+
+ const isControlled = Array.isArray(value);
+ const fromDate = isControlled ? (value?.[0] ?? "") : internalFrom;
+ const toDate = isControlled ? (value?.[1] ?? "") : internalTo;
+
+ const {
+ open,
+ setOpen,
+ formattedMonth,
+ datesAsWeeks,
+ currentMonth,
+ currentYear,
+ view,
+ cycleView,
+ selectMonth,
+ selectYear,
+ yearRangeStart,
+ yearRange,
+ prev,
+ next,
+ resetView,
+ months,
+ today,
+ syncCalendarToValue,
+ resetCalendarToToday,
+ } = useDatePicker({
+ value: fromDate,
+ onChange: () => {},
+ });
+
+ const commitRange = useCallback(
+ (from: string, to: string) => {
+ setInternalFrom(from);
+ setInternalTo(to);
+ onChange?.([from, to]);
+ },
+ [onChange]
+ );
+
+ const isDateDisallowed = useCallback(
+ (date: Date) => {
+ if (!disallowAfter) return false;
+ return getDateValue(date).slice(0, 10) > disallowAfter.slice(0, 10);
+ },
+ [disallowAfter]
+ );
+
+ const handleDateClick = useCallback(
+ (date: Date): boolean => {
+ if (isDateDisallowed(date)) {
+ return false;
+ }
+
+ // Zero out time for date
+ const d = new Date(date);
+ d.setHours(0, 0, 0, 0);
+ const v = getDateValue(d);
+ syncCalendarToValue(v);
+
+ // Starting a range commits the start date with a pending, empty end date.
+ if (!fromDate || toDate) {
+ commitRange(v, "");
+ return false;
+ }
+
+ const isReversed = fromDate > v;
+ commitRange(isReversed ? v : fromDate, isReversed ? fromDate : v);
+ return true;
+ },
+ [commitRange, fromDate, isDateDisallowed, syncCalendarToValue, toDate]
+ );
+
+ const isTodayDisallowed = isDateDisallowed(today);
+
+ const handleToday = useCallback(() => {
+ const d = new Date(today);
+ d.setHours(0, 0, 0, 0);
+ if (isDateDisallowed(d)) {
+ return false;
+ }
+ const todayStr = getDateValue(d);
+ syncCalendarToValue(todayStr);
+ commitRange(todayStr, todayStr);
+ return true;
+ }, [commitRange, isDateDisallowed, syncCalendarToValue, today]);
+
+ const clearDates = useCallback(() => {
+ syncCalendarToValue("");
+ resetCalendarToToday();
+ commitRange("", "");
+ }, [commitRange, resetCalendarToToday, syncCalendarToValue]);
+
+ const selectDates = useCallback(() => {
+ onChange?.([fromDate, toDate]);
+ setOpen(false);
+ }, [fromDate, onChange, setOpen, toDate]);
+
+ const applyRange = useCallback(
+ (from: string, to: string) => {
+ syncCalendarToValue(from);
+ commitRange(from, to);
+ },
+ [commitRange, syncCalendarToValue]
+ );
+
+ const isInRange = useCallback(
+ (date: Date) => {
+ if (!fromDate || !toDate) return false;
+ return date >= getDate(fromDate) && date <= getDate(toDate);
+ },
+ [fromDate, toDate]
+ );
+
+ return {
+ open,
+ setOpen,
+ fromDate,
+ toDate,
+ formattedMonth,
+ datesAsWeeks,
+ currentMonth,
+ currentYear,
+ view,
+ cycleView,
+ selectMonth,
+ selectYear,
+ yearRangeStart,
+ yearRange,
+ prev,
+ next,
+ resetView,
+ months,
+ today,
+ handleToday,
+ handleDateClick,
+ clearDates,
+ selectDates,
+ applyRange,
+ isInRange,
+ isDateDisallowed,
+ isTodayDisallowed,
+ };
+}
diff --git a/packages/frappe-ui-react/src/components/datePicker/utils.ts b/packages/frappe-ui-react/src/components/datePicker/utils.ts
index ce12b1c4..37130050 100644
--- a/packages/frappe-ui-react/src/components/datePicker/utils.ts
+++ b/packages/frappe-ui-react/src/components/datePicker/utils.ts
@@ -1,4 +1,5 @@
import { dayjs } from "../../utils/dayjs";
+import type { PopoverSide, PopoverAlign } from "./types";
type DateConstructorParam = string | number | Date;
@@ -69,3 +70,16 @@ export function isLeapYear(year: number) {
if (year % 4 === 0) return true;
return false;
}
+
+// Parses placement string like "bottom-end" into side and align.
+// This is for backwards compatibility, as older versions of date picker used popperjs.
+export function parsePlacement(placement?: string): {
+ side: PopoverSide;
+ align: PopoverAlign;
+} {
+ if (!placement) return { side: "bottom", align: "start" };
+ const parts = placement.split("-");
+ const side = (parts[0] as PopoverSide) || "bottom";
+ const align = (parts[1] as PopoverAlign) || "center";
+ return { side, align };
+}
diff --git a/packages/frappe-ui-react/src/components/dialog/dialog.stories.tsx b/packages/frappe-ui-react/src/components/dialog/dialog.stories.tsx
index 890ec9e8..6b84a3a3 100644
--- a/packages/frappe-ui-react/src/components/dialog/dialog.stories.tsx
+++ b/packages/frappe-ui-react/src/components/dialog/dialog.stories.tsx
@@ -53,6 +53,14 @@ const meta: Meta = {
description:
"Callback function called after the dialog has fully closed.",
},
+ className: {
+ control: "text",
+ description: "Extra classes merged onto the popup box itself.",
+ },
+ classNames: {
+ control: "object",
+ description: "Classname overrides for the dialog's inner elements.",
+ },
},
};
diff --git a/packages/frappe-ui-react/src/components/dialog/dialog.tsx b/packages/frappe-ui-react/src/components/dialog/dialog.tsx
index 222586a4..99c0dfed 100644
--- a/packages/frappe-ui-react/src/components/dialog/dialog.tsx
+++ b/packages/frappe-ui-react/src/components/dialog/dialog.tsx
@@ -1,13 +1,50 @@
+/**
+ * External dependencies.
+ */
import { useMemo } from "react";
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
-import { X } from "lucide-react";
-import clsx from "clsx";
+import { cva } from "class-variance-authority";
+
+/**
+ * Internal dependencies.
+ */
+import { cn } from "../../utils";
import { Button } from "../button";
import FeatherIcon, { type FeatherIconProps } from "../featherIcon";
import { DialogActionButton } from "./dialogActionButton";
+import { Close } from "../../icons";
import "./dialog.css";
import type { DialogProps } from "./types";
+const iconWrapperVariants = cva(
+ "flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
+ {
+ variants: {
+ appearance: {
+ default: "bg-surface-gray-2",
+ info: "bg-surface-gray-2",
+ warning: "bg-surface-amber-2",
+ danger: "bg-surface-red-2",
+ success: "bg-surface-green-2",
+ },
+ },
+ defaultVariants: { appearance: "default" },
+ }
+);
+
+const iconVariants = cva("size-4", {
+ variants: {
+ appearance: {
+ default: "text-ink-gray-5",
+ info: "text-ink-blue-3",
+ warning: "text-ink-amber-3",
+ danger: "text-ink-red-4",
+ success: "text-ink-green-3",
+ },
+ },
+ defaultVariants: { appearance: "default" },
+});
+
const Dialog = ({
open,
onOpenChange,
@@ -16,6 +53,8 @@ const Dialog = ({
onAfterLeave,
children,
actions: customActions,
+ className,
+ classNames = {},
}: DialogProps) => {
const {
title,
@@ -41,54 +80,6 @@ const Dialog = ({
[position]
);
- const dialogIconBgClasses = useMemo(() => {
- if (!icon?.appearance) {
- return "bg-surface-gray-2";
- }
-
- if (icon.appearance === "warning") {
- return "bg-surface-amber-2";
- }
-
- if (icon.appearance === "info") {
- return "bg-surface-gray-2";
- }
-
- if (icon.appearance === "danger") {
- return "bg-surface-red-2";
- }
-
- if (icon.appearance === "success") {
- return "bg-surface-green-2";
- }
-
- return "";
- }, [icon]);
-
- const dialogIconClasses = useMemo(() => {
- if (!icon?.appearance) {
- return "text-ink-gray-5";
- }
-
- if (icon.appearance === "warning") {
- return "text-ink-amber-3";
- }
-
- if (icon.appearance === "info") {
- return "text-ink-blue-3";
- }
-
- if (icon.appearance === "danger") {
- return "text-ink-red-4";
- }
-
- if (icon.appearance === "success") {
- return "text-ink-green-3";
- }
-
- return "";
- }, [icon]);
-
return (
!open && onAfterLeave?.()}
>
-
+
-
-
+
+
{icon && (
@@ -148,7 +166,10 @@ const Dialog = ({
render={
typeof title === "string" || !title ? (
{title || "Untitled"}
@@ -164,9 +185,15 @@ const Dialog = ({
-
+
}
nativeButton={true}
@@ -179,7 +206,10 @@ const Dialog = ({
{message}
@@ -192,11 +222,13 @@ const Dialog = ({
{(actions.length > 0 || customActions) && (
-
+
{customActions ? (
customActions
) : (
-
+
{actions.map((action) => (
void;
@@ -45,4 +74,8 @@ export interface DialogProps {
onAfterLeave?: () => void;
children?: React.ReactNode;
actions?: React.ReactNode;
+ /** Class for the popup (the modal box itself). */
+ className?: string;
+ /** Per-slot class overrides for the dialog's inner elements. */
+ classNames?: DialogClassNames;
}
diff --git a/packages/frappe-ui-react/src/components/divider/divider.tsx b/packages/frappe-ui-react/src/components/divider/divider.tsx
index c49b7b0e..35657056 100644
--- a/packages/frappe-ui-react/src/components/divider/divider.tsx
+++ b/packages/frappe-ui-react/src/components/divider/divider.tsx
@@ -1,13 +1,14 @@
import { useMemo } from "react";
-import clsx from "clsx";
import { Button } from "../button";
import type { DividerProps } from "./types";
+import { cn } from "../../utils";
const Divider = ({
orientation = "horizontal",
position = "center",
flexItem = false,
+ className = "",
action,
}: DividerProps) => {
const alignmentClasses = useMemo(() => {
@@ -38,12 +39,13 @@ const Divider = ({
return action ? (
-
+
) : (
);
diff --git a/packages/frappe-ui-react/src/components/divider/types.ts b/packages/frappe-ui-react/src/components/divider/types.ts
index 9b5ced05..927dad77 100644
--- a/packages/frappe-ui-react/src/components/divider/types.ts
+++ b/packages/frappe-ui-react/src/components/divider/types.ts
@@ -10,4 +10,5 @@ export interface DividerProps {
position?: "start" | "center" | "end";
flexItem?: boolean;
action?: DividerAction;
+ className?: string
}
diff --git a/packages/frappe-ui-react/src/components/dropdown/dropdown.interactions.stories.tsx b/packages/frappe-ui-react/src/components/dropdown/dropdown.interactions.stories.tsx
new file mode 100644
index 00000000..4a77590f
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/dropdown/dropdown.interactions.stories.tsx
@@ -0,0 +1,74 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, screen, userEvent, waitFor } from "storybook/test";
+
+import Dropdown from "./dropdown";
+import { DotHorizontal } from "../../icons";
+import type { DropdownOptions } from "./types";
+
+const meta: Meta = {
+ title: "Components/Dropdown/Interactions",
+ component: Dropdown,
+ parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
+};
+
+export default meta;
+type Story = StoryObj;
+
+const onViewSelect = fn();
+const onActionSelect = fn();
+
+const views: DropdownOptions = [
+ {
+ label: "Kanban",
+ key: "kanban",
+ onClick: () => onViewSelect(),
+ },
+];
+
+const viewActions: DropdownOptions = [
+ {
+ label: "Duplicate",
+ onClick: () => onActionSelect(),
+ },
+];
+
+export const NestedDropdownClosesBothMenus: Story = {
+ args: {
+ options: views,
+ button: { label: "Views" },
+ renderMenuItem: (props, state) => (
+
+ {props.children}
+
+
+ event.stopPropagation()}
+ >
+
+
+
+
+
+ ),
+ },
+ play: async ({ canvas }) => {
+ onViewSelect.mockClear();
+ onActionSelect.mockClear();
+
+ await userEvent.click(canvas.getByRole("button", { name: "Views" }));
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Actions for Kanban" })
+ );
+
+ await waitFor(() => expect(screen.getAllByRole("menu")).toHaveLength(2));
+
+ await userEvent.click(await screen.findByText("Duplicate"));
+
+ await waitFor(() => expect(screen.queryAllByRole("menu")).toHaveLength(0));
+ await expect(onActionSelect).toHaveBeenCalledTimes(1);
+ await expect(onViewSelect).not.toHaveBeenCalled();
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx b/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx
index 0f3bfa12..4387510a 100644
--- a/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx
+++ b/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx
@@ -4,6 +4,7 @@ import { action } from "storybook/actions";
import { useState } from "react";
import Dropdown from "./dropdown";
import { Button } from "../button";
+import { Calendar, Delete, DotHorizontal, Duplicate, Edit, Kanban, List } from "../../icons";
import type { DropdownOptions } from "./types";
export default {
@@ -12,22 +13,49 @@ export default {
argTypes: {
options: {
control: "object",
- description:
- "An array of dropdown options, which can be individual items or groups.",
+ description: "An array of dropdown options, which can be individual items or groups.",
},
placement: {
control: { type: "select", options: ["left", "right", "center"] },
description: "Placement of the dropdown content relative to the trigger.",
},
+ side: {
+ control: { type: "select", options: ["top", "bottom", "left", "right"] },
+ description: "Which side of the trigger the dropdown opens on.",
+ },
button: {
control: "object",
- description:
- "Props for the default button trigger if no children are provided.",
+ description: "Props for the default button trigger if no children are provided.",
},
children: {
control: "text",
description: "Custom trigger element for the dropdown.",
},
+ dropdownClassName: {
+ control: "text",
+ description: "Additional class name for the dropdown content.",
+ },
+ groupClassName: {
+ control: "text",
+ description: "Additional class name for dropdown groups.",
+ },
+ renderItems: {
+ control: false,
+ description: "Function to render custom dropdown items, receives options as argument.",
+ },
+ renderMenuItem: {
+ control: false,
+ description:
+ "Overrides the rendering of plain menu items. Receives the default item props (className, children with icon + label) and `{ item }` as state.",
+ },
+ selectedKey: {
+ control: "text",
+ description: "Key of the currently selected dropdown item, used for highlighting.",
+ },
+ selectedGroupKey: {
+ control: "text",
+ description: "Key of the currently selected dropdown group, used for highlighting.",
+ },
},
parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
tags: ["autodocs"],
@@ -313,3 +341,93 @@ export const WithNestedSubmenus: StoryObj = {
button: { label: "Nested Submenus" },
},
};
+
+export const OpensToTheRight: StoryObj = {
+ ...DropdownTemplate,
+ args: {
+ options: actions,
+ side: "right",
+ button: { label: "Opens Right" },
+ },
+};
+
+const views: DropdownOptions = [
+ {
+ label: "List",
+ key: "list",
+ icon:
,
+ onClick: () => action("List clicked")(),
+ },
+ {
+ label: "Kanban",
+ key: "kanban",
+ icon: ,
+ onClick: () => action("Kanban clicked")(),
+ },
+ {
+ label: "Calendar",
+ key: "calendar",
+ icon: ,
+ onClick: () => action("Calendar clicked")(),
+ },
+];
+
+const viewActions: DropdownOptions = [
+ {
+ group: "",
+ key: "view-actions",
+ items: [
+ {
+ label: "Duplicate",
+ icon: ,
+ onClick: () => action("Duplicate clicked")(),
+ },
+ {
+ label: "Edit",
+ icon: ,
+ onClick: () => action("Edit clicked")(),
+ },
+ ],
+ },
+ {
+ group: "",
+ key: "view-danger-actions",
+ items: [
+ {
+ label: "Delete",
+ icon: ,
+ theme: "red",
+ onClick: () => action("Delete clicked")(),
+ },
+ ],
+ },
+];
+
+export const WithRenderMenuItem: StoryObj = {
+ ...DropdownTemplate,
+ args: {
+ options: views,
+ selectedKey: "list",
+ button: { label: "Views" },
+ renderMenuItem: (props, state) =>
+ state.item.key === "list" ? (
+
+ ) : (
+
+ {props.children}
+
+
+ event.stopPropagation()}
+ >
+
+
+
+
+
+ ),
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/dropdown/dropdown.tsx b/packages/frappe-ui-react/src/components/dropdown/dropdown.tsx
index c2bd07b7..c7d415c9 100644
--- a/packages/frappe-ui-react/src/components/dropdown/dropdown.tsx
+++ b/packages/frappe-ui-react/src/components/dropdown/dropdown.tsx
@@ -1,6 +1,13 @@
+/**
+ * External dependencies.
+ */
import React, { useMemo, useCallback } from "react";
import { Menu } from "@base-ui/react/menu";
+import { useRender } from "@base-ui/react/use-render";
+/**
+ * Internal dependencies.
+ */
import { Button, type ButtonProps } from "../button";
import { Switch } from "../switch";
import type {
@@ -8,29 +15,62 @@ import type {
DropdownOption,
DropdownGroupOption,
DropdownOptions,
+ DropdownItemState,
+ DropdownRenderMenuItem,
} from "./types";
import FeatherIcon, { type FeatherIconProps } from "../featherIcon";
-import clsx from "clsx";
+import { cn } from "../../utils";
const cssClasses = {
dropdownContent:
"min-w-40 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-black focus:outline-none dropdown-content border border-outline-gray-1",
groupContainer: "p-1.5",
groupLabel: "flex h-7 items-center px-2 text-sm font-medium text-ink-gray-7",
- itemLabel: "whitespace-nowrap",
+ itemLabel: "truncate",
itemIcon: "mr-2 h-4 w-4 flex-shrink-0",
chevronIcon: "ml-auto h-4 w-4 flex-shrink-0",
- itemButton:
- "group flex h-7 w-full items-center rounded px-2 text-base focus:outline-none",
- submenuTrigger:
- "group flex h-7 w-full items-center rounded px-2 text-base text-ink-gray-6 focus:outline-none",
- dropdownPositioner: "z-100",
+ itemButton: "group flex h-7 w-full items-center rounded px-2 text-base focus:outline-none",
+ submenuTrigger: "group flex h-7 w-full items-center rounded px-2 text-base text-ink-gray-6 focus:outline-none",
+ dropdownPositioner: "z-100 py-1",
+};
+
+const itemStateAttributesMapping = {
+ item: () => null,
+};
+
+const DropdownItem = ({
+ item,
+ render,
+ ref,
+ ...props
+}: React.HTMLAttributes & {
+ item: DropdownOption;
+ render?: DropdownRenderMenuItem;
+ ref?: React.Ref;
+}) => {
+ const state: DropdownItemState = useMemo(() => ({ item }), [item]);
+ return useRender({
+ render: render as useRender.RenderProp,
+ defaultTagName: "button",
+ ref,
+ state,
+ stateAttributesMapping: itemStateAttributesMapping,
+ props,
+ });
};
const Dropdown: React.FC = ({
options = [],
placement = "left",
+ side = "bottom",
+ dropdownClassName = "",
+ groupClassName = "",
+ itemClassName = "",
+ selectedKey,
+ selectedGroupKey,
button,
+ renderItems,
+ renderMenuItem,
children,
...attrs
}) => {
@@ -42,11 +82,9 @@ const Dropdown: React.FC = ({
}
}, []);
- const getIconColor = (item: DropdownOption) =>
- item.theme === "red" ? "text-ink-red-3" : "text-ink-gray-6";
+ const getIconColor = (item: DropdownOption) => (item.theme === "red" ? "text-ink-red-3" : "text-ink-gray-6");
- const getTextColor = (item: DropdownOption) =>
- item.theme === "red" ? "text-ink-red-3" : "text-ink-gray-7";
+ const getTextColor = (item: DropdownOption) => (item.theme === "red" ? "text-ink-red-3" : "text-ink-gray-7");
const getBackgroundColor = (item: DropdownOption) =>
item.theme === "red"
@@ -54,17 +92,16 @@ const Dropdown: React.FC = ({
: "focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3";
const getSubmenuBackgroundColor = (item: DropdownOption) =>
- clsx(
+ cn(
getBackgroundColor(item),
- item.theme === "red"
- ? " data-[state=open]:bg-surface-red-3"
- : " data-[state=open]:bg-surface-gray-4"
+ item.theme === "red" ? " data-[state=open]:bg-surface-red-3" : " data-[state=open]:bg-surface-gray-4"
);
const normalizeDropdownItem = useCallback(
(option: DropdownOption): DropdownOption => {
return {
label: option.label,
+ key: option.key,
icon: option.icon,
component: option.component,
onClick: option.switch ? option.onClick : () => handleItemClick(option),
@@ -85,10 +122,7 @@ const Dropdown: React.FC = ({
.filter(Boolean)
.filter(
(option) =>
- !("group" in option) &&
- ("condition" in option
- ? ((option as DropdownOption).condition?.() ?? true)
- : true)
+ !("group" in option) && ("condition" in option ? ((option as DropdownOption).condition?.() ?? true) : true)
)
.map((option) => normalizeDropdownItem(option as DropdownOption));
},
@@ -111,9 +145,12 @@ const Dropdown: React.FC = ({
groups.push(currentGroup);
currentGroup = null;
}
- const groupOption: DropdownGroupOption = {
+ const groupOption: DropdownGroupOption & {
+ groupKey?: string | number;
+ } = {
...option,
key: `group-${i}`,
+ groupKey: option.key,
items: filterAndNormalizeOptions(option.items),
} as DropdownGroupOption;
groups.push(groupOption);
@@ -143,12 +180,9 @@ const Dropdown: React.FC = ({
[filterAndNormalizeOptions]
);
- const groups = useMemo(
- () => processOptionsIntoGroups(options),
- [options, processOptionsIntoGroups]
- );
+ const groups = useMemo(() => processOptionsIntoGroups(options), [options, processOptionsIntoGroups]);
- const contentSide = "bottom";
+ const contentSide = side;
const contentAlign = useMemo(() => {
if (placement === "left") return "start";
if (placement === "right") return "end";
@@ -156,21 +190,18 @@ const Dropdown: React.FC = ({
return "start";
}, [placement]);
- const renderDropdownItem = (item: DropdownOption) => {
+ const renderDropdownItem = (item: DropdownOption & { groupKey?: string | number }) => {
if (item.component) {
const CustomComponent = item.component;
return ;
} else if (item.switch) {
return (
- e.preventDefault()}
- >
+
e.preventDefault()}>
{item.icon &&
(typeof item.icon === "string" ? (
) : React.isValidElement(item.icon) ? (
item.icon
@@ -195,28 +226,18 @@ const Dropdown: React.FC
= ({
(typeof item.icon === "string" ? (
) : React.isValidElement(item.icon) ? (
item.icon
) : null)
}
iconRight={() => (
-
- )}
- className={clsx(
- cssClasses.submenuTrigger,
- getSubmenuBackgroundColor(item)
+
)}
+ className={cn(cssClasses.submenuTrigger, getSubmenuBackgroundColor(item))}
>
-
+
{item.label}
@@ -224,31 +245,23 @@ const Dropdown: React.FC = ({
nativeButton={true}
/>
-
+
{processOptionsIntoGroups(item.submenu).map((submenuGroup) => (
-
+
{submenuGroup.group && !submenuGroup.hideLabel && (
-
- {submenuGroup.group}
-
+ {submenuGroup.group}
)}
{submenuGroup.items.map((subItem) => (
subItem.onClick?.()}
+ onClick={(event) => {
+ if (event.currentTarget.contains(event.target as Node)) {
+ subItem.onClick?.();
+ }
+ }}
render={renderDropdownItem(subItem)}
- nativeButton={
- !subItem.switch &&
- !subItem.submenu &&
- !subItem.component
- }
+ nativeButton={!subItem.switch && !subItem.submenu && !subItem.component && !renderMenuItem}
/>
))}
@@ -260,23 +273,30 @@ const Dropdown: React.FC = ({
);
} else {
return (
-
{item.icon &&
(typeof item.icon === "string" ? (
-
+
) : React.isValidElement(item.icon) ? (
item.icon
) : null)}
{item.label}
-
+
);
}
};
@@ -288,11 +308,7 @@ const Dropdown: React.FC = ({
children ? (
React.cloneElement(children as React.ReactElement, { ...attrs })
) : (
-
+
{button?.label || "Options"}
)
@@ -307,33 +323,46 @@ const Dropdown: React.FC = ({
className={cssClasses.dropdownPositioner}
>
- {groups.map((group) => (
-
- {group.group && !group.hideLabel && (
-
- {group.group}
-
- )}
- {group.items.map((item) => (
-
-
!item.switch && item.onClick?.()}
- render={renderDropdownItem(item)}
- nativeButton={
- !item.switch && !item.submenu && !item.component
- }
- />
-
+ {renderItems
+ ? renderItems(options)
+ : groups.map((group) => (
+
+ {group.group && !group.hideLabel && (
+ {group.group}
+ )}
+ {group.items.map((item) => (
+
+
{
+ // Clicks bubbling out of menus nested via renderMenuItem
+ // reach this handler through the React tree but originate
+ // outside the item's DOM — they must close the menu
+ // without triggering the item's own action.
+ if (!item.switch && event.currentTarget.contains(event.target as Node)) {
+ item.onClick?.();
+ }
+ }}
+ render={renderDropdownItem({
+ ...item,
+ groupKey: group.groupKey,
+ })}
+ nativeButton={!item.switch && !item.submenu && !item.component && !renderMenuItem}
+ />
+
+ ))}
+
))}
-
- ))}
diff --git a/packages/frappe-ui-react/src/components/dropdown/types.ts b/packages/frappe-ui-react/src/components/dropdown/types.ts
index 995b9f7b..4bfa4878 100644
--- a/packages/frappe-ui-react/src/components/dropdown/types.ts
+++ b/packages/frappe-ui-react/src/components/dropdown/types.ts
@@ -1,9 +1,10 @@
-import type { ReactNode, ComponentType } from "react";
-import type { ButtonProps } from "@headlessui/react";
+import type { ReactElement, ReactNode, ComponentType } from "react";
+import type { ButtonProps } from "../button";
import type { ButtonTheme } from "../button";
export interface DropdownOption {
label: string;
+ key?: string | number;
onClick?: (val?: boolean) => void;
link?: string;
icon?: string | ReactNode;
@@ -19,6 +20,7 @@ export interface DropdownOption {
export interface DropdownGroupOption {
key: string | number;
+ groupKey?: string | number;
group?: string;
hideLabel?: boolean;
items: DropdownOption[];
@@ -26,11 +28,46 @@ export interface DropdownGroupOption {
export type DropdownOptions = (DropdownOption | DropdownGroupOption)[];
+export type DropdownItemState = {
+ item: DropdownOption;
+};
+
+/**
+ * Default props of a menu item, forwarded to `renderMenuItem`. Kept
+ * structural (no React.HTMLAttributes) so consumers with their own copy of
+ * @types/react can spread them onto JSX without type-identity conflicts.
+ */
+export type DropdownItemRenderProps = {
+ className?: string;
+ children?: ReactNode;
+ [key: string]: unknown;
+};
+
+export type DropdownRenderMenuItem =
+ | ReactElement
+ | ((
+ props: DropdownItemRenderProps,
+ state: DropdownItemState
+ ) => ReactElement);
+
export interface DropdownProps {
options: DropdownOptions;
placement?: "left" | "right" | "center";
+ /** Which side of the trigger the dropdown opens on. */
+ side?: "top" | "bottom" | "left" | "right";
+ dropdownClassName?: string;
+ groupClassName?: string;
+ itemClassName?: string;
+ selectedKey?: string | number;
+ selectedGroupKey?: string | number;
button?: Omit & {
label?: string;
};
children?: ReactNode;
+ renderItems?: (options: DropdownOptions) => ReactNode;
+ /**
+ * Overrides the rendering of plain menu items. Receives the default item
+ * props (className, children with icon + label) and `{ item }` as state.
+ */
+ renderMenuItem?: DropdownRenderMenuItem;
}
diff --git a/packages/frappe-ui-react/src/components/durationInput/durationInput.interactions.stories.tsx b/packages/frappe-ui-react/src/components/durationInput/durationInput.interactions.stories.tsx
new file mode 100644
index 00000000..25fa648b
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/durationInput/durationInput.interactions.stories.tsx
@@ -0,0 +1,177 @@
+import { useState, type ComponentProps } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, userEvent, waitFor, within } from "storybook/test";
+
+import DurationInput from "./durationInput";
+
+const meta: Meta = {
+ title: "Components/DurationInput/Interactions",
+ component: DurationInput,
+ parameters: {
+ docs: { source: { type: "dynamic" } },
+ layout: "centered",
+ },
+ argTypes: {
+ size: {
+ control: "select",
+ options: ["sm", "md"],
+ description: "Height of the duration input",
+ },
+ variant: {
+ control: "select",
+ options: ["subtle", "outline"],
+ description: "Visual style of the duration input",
+ },
+ value: {
+ control: "number",
+ description: "Current duration value in hours",
+ },
+ hoursLeft: {
+ control: "number",
+ description:
+ "Remaining time used to calculate left or over state, in hours",
+ },
+ snap: {
+ control: "select",
+ options: ["step", "smooth"],
+ description:
+ "Controls whether the slider snaps continuously or moves smoothly",
+ },
+ maxDuration: {
+ control: "number",
+ description: "Maximum allowed duration in hours",
+ },
+ disabled: {
+ control: "boolean",
+ description: "Disables the slider and input",
+ },
+ loading: {
+ control: "boolean",
+ description: "Shows the loading spinner before the input",
+ },
+ error: {
+ control: "boolean",
+ description: "Applies error styling",
+ },
+ allowOverflow: {
+ control: "boolean",
+ description:
+ "Allows manual text input to exceed maxDuration while keeping the slider capped",
+ },
+ className: {
+ control: "text",
+ description: "Class applied to the root wrapper",
+ },
+ classNames: {
+ control: "object",
+ description: "Per-slot class overrides for the duration input internals",
+ },
+ onChange: {
+ action: "changed",
+ description: "Callback fired when the value changes",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+function ControlledDurationInput(args: ComponentProps) {
+ const [value, setValue] = useState(args.value ?? 0);
+
+ const handleChange = (nextValue: number) => {
+ setValue(nextValue);
+ args.onChange?.(nextValue);
+ };
+
+ return ;
+}
+
+export const Input: Story = {
+ args: {
+ label: "Duration",
+ value: 0,
+ hoursLeft: 8,
+ maxDuration: 8,
+ snap: "step",
+ onChange: fn(),
+ },
+ render: (args) => (
+
+
+
+ ),
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+ const enterDuration = async (
+ duration: string,
+ expectedChange: number,
+ expectedValue: string
+ ) => {
+ await userEvent.click(input);
+ await userEvent.clear(input);
+ await userEvent.type(input, duration);
+
+ await waitFor(() => {
+ expect(input).toHaveValue(duration);
+ });
+
+ await userEvent.tab();
+
+ await waitFor(() => {
+ expect(args.onChange).toHaveBeenCalledWith(expectedChange);
+ expect(input).toHaveValue(expectedValue);
+ });
+ };
+
+ await enterDuration("1", 1, "01:00");
+
+ await userEvent.click(input);
+ await userEvent.clear(input);
+ await userEvent.type(input, "7");
+ await userEvent.keyboard("{Escape}");
+
+ await waitFor(() => {
+ expect(input).toHaveValue("01:00");
+ });
+
+ expect(args.onChange).toHaveBeenCalledTimes(1);
+
+ await enterDuration("1.5", 1.5, "01:30");
+ await enterDuration("5:3", 5.05, "05:03");
+ await enterDuration(".5", 0.5, "00:30");
+ await enterDuration(":30", 0.5, "00:30");
+ await enterDuration("5:", 5, "05:00");
+ await enterDuration("1:75", 2.25, "02:15");
+ },
+};
+
+export const Slider: Story = {
+ args: {
+ label: "Duration",
+ value: 3.5,
+ hoursLeft: 4,
+ maxDuration: 8,
+ snap: "step",
+ onChange: fn(),
+ },
+ render: (args) => (
+
+
+
+ ),
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ const slider = canvas.getByRole("slider", { name: "Duration" });
+
+ await userEvent.click(slider);
+ await userEvent.keyboard("{ArrowRight}{ArrowRight}");
+
+ await waitFor(() => {
+ expect(args.onChange).toHaveBeenLastCalledWith(4.5);
+ expect(canvas.getByRole("textbox")).toHaveValue("04:30");
+ expect(canvas.getByText("30m over")).toHaveClass("text-ink-red-4");
+ });
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/durationInput/durationInput.stories.tsx b/packages/frappe-ui-react/src/components/durationInput/durationInput.stories.tsx
new file mode 100644
index 00000000..a5853c71
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/durationInput/durationInput.stories.tsx
@@ -0,0 +1,215 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import DurationInput from "./durationInput";
+import type { DurationInputProps } from "./types";
+
+export default {
+ title: "Components/DurationInput",
+ component: DurationInput,
+ parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
+ tags: ["autodocs"],
+ args: {
+ size: "sm",
+ variant: "subtle",
+ snap: "step",
+ loading: false,
+ error: false,
+ disabled: false,
+ allowOverflow: false,
+ value: 0.5,
+ },
+ argTypes: {
+ label: {
+ control: "text",
+ description: "Label displayed above the input",
+ table: {
+ type: {
+ summary: "string | false",
+ },
+ },
+ },
+ maxDuration: {
+ control: "number",
+ description: "Maximum allowed duration in hours",
+ },
+ hoursLeft: {
+ control: "number",
+ description:
+ "Remaining time available in hours (used to calculate 'Xh Ym left' or 'Xh Ym over')",
+ },
+ snap: {
+ control: "select",
+ options: ["step", "smooth"],
+ description:
+ "Controls whether the slider snaps continuously or moves smoothly",
+ },
+ value: {
+ control: "number",
+ description: "Current duration value in hours",
+ },
+ size: {
+ control: "select",
+ options: ["sm", "md"],
+ description: "Height of the duration input",
+ },
+ variant: {
+ control: "select",
+ options: ["subtle", "outline"],
+ description: "Visual style of the duration input",
+ },
+ disabled: {
+ control: "boolean",
+ description: "If true, disables the slider and text input",
+ },
+ loading: {
+ control: "boolean",
+ description: "If true, shows a spinner before the input value",
+ },
+ error: {
+ control: "boolean",
+ description: "If true, applies error styling",
+ },
+ allowOverflow: {
+ control: "boolean",
+ description:
+ "Allows manual text input to exceed maxDuration while keeping the slider capped",
+ },
+ className: {
+ control: "text",
+ description: "Class applied to the root wrapper",
+ },
+ classNames: {
+ control: "object",
+ description: "Per-slot class overrides for the duration input internals",
+ },
+ onChange: {
+ action: "changed",
+ description: "Callback fired with the new duration value in hours",
+ },
+ },
+} as Meta;
+
+const Template: StoryObj = {
+ render: (args) => {
+ const [value, setValue] = useState(args.value ?? 0.5);
+
+ return (
+
+ {
+ setValue(v);
+ }}
+ />
+
+ );
+ },
+};
+
+export const Default: StoryObj = {
+ ...Template,
+ args: {
+ label: "Duration",
+ size: "sm",
+ variant: "subtle",
+ snap: "step",
+ maxDuration: 8,
+ hoursLeft: 8,
+ value: 0.5,
+ },
+};
+
+export const WithInitialValue: StoryObj = {
+ ...Template,
+ args: {
+ label: "Duration",
+ size: "sm",
+ variant: "subtle",
+ snap: "step",
+ maxDuration: 8,
+ hoursLeft: 5.5,
+ value: 2.5,
+ },
+};
+
+export const OverHours: StoryObj = {
+ ...Template,
+ args: {
+ label: "Duration",
+ size: "sm",
+ variant: "subtle",
+ snap: "step",
+ maxDuration: 8,
+ hoursLeft: 1,
+ value: 6,
+ },
+};
+
+export const CustomMax: StoryObj = {
+ ...Template,
+ args: {
+ label: "Sprint Duration",
+ size: "sm",
+ variant: "subtle",
+ snap: "step",
+ maxDuration: 10,
+ hoursLeft: 10,
+ value: 0.5,
+ },
+};
+
+export const Disabled: StoryObj = {
+ ...Template,
+ args: {
+ label: "Duration",
+ size: "sm",
+ variant: "subtle",
+ snap: "step",
+ maxDuration: 8,
+ hoursLeft: 5,
+ value: 0.5,
+ disabled: true,
+ },
+};
+
+export const Outline: StoryObj = {
+ ...Template,
+ args: {
+ label: "Duration",
+ size: "sm",
+ variant: "outline",
+ snap: "step",
+ maxDuration: 8,
+ hoursLeft: 8,
+ value: 2,
+ },
+};
+
+export const Loading: StoryObj = {
+ ...Template,
+ args: {
+ label: "Duration",
+ size: "sm",
+ variant: "subtle",
+ snap: "step",
+ loading: true,
+ maxDuration: 8,
+ hoursLeft: 8,
+ value: 2,
+ },
+};
+
+export const Error: StoryObj = {
+ ...Template,
+ args: {
+ label: "Duration",
+ size: "sm",
+ variant: "subtle",
+ snap: "step",
+ error: true,
+ maxDuration: 8,
+ hoursLeft: 8,
+ value: 2,
+ },
+};
diff --git a/packages/frappe-ui-react/src/components/durationInput/durationInput.tsx b/packages/frappe-ui-react/src/components/durationInput/durationInput.tsx
new file mode 100644
index 00000000..93de050c
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/durationInput/durationInput.tsx
@@ -0,0 +1,412 @@
+/**
+ * External dependencies.
+ */
+import { useCallback, useId, useState } from "react";
+import { cva } from "class-variance-authority";
+import { Slider } from "@base-ui/react/slider";
+
+/**
+ * Internal dependencies.
+ */
+import {
+ clampHours,
+ floatToTime,
+ formatHoursBalance,
+ getPreviewMinutes,
+ getSliderHours,
+ getSliderMinutes,
+ normalizeCommittedHours,
+ SLIDER_STEP_MINUTES,
+ sanitizeHoursInput,
+ timeToFloat,
+} from "./utils";
+import type { DurationInputProps } from "./types";
+import { cn } from "../../utils";
+import { useDurationSlider } from "./useDurationSlider";
+import { Spinner } from "../spinner";
+
+type SliderValue = number | readonly number[];
+
+const durationControlVariants = cva(
+ "flex items-center rounded relative overflow-hidden has-focus-visible:border-outline-gray-4 has-focus-visible:shadow-sm cursor-pointer data-dragging:cursor-grabbing data-disabled:cursor-not-allowed",
+ {
+ variants: {
+ variant: {
+ subtle: "",
+ outline: "ring-1 ring-outline-gray-2",
+ },
+ error: {
+ true: "has-focus-visible:outline-2 has-focus-visible:outline-red",
+ false: "has-focus-visible:outline-2 has-focus-visible:outline-default",
+ },
+ },
+ compoundVariants: [
+ {
+ variant: "outline",
+ error: true,
+ className: "ring-outline-red-2",
+ },
+ ],
+ defaultVariants: {
+ variant: "subtle",
+ error: false,
+ },
+ }
+);
+
+const durationTrackVariants = cva("w-full rounded transition-colors", {
+ variants: {
+ size: {
+ sm: "h-7",
+ md: "h-8",
+ },
+ variant: {
+ subtle: "bg-surface-gray-2",
+ outline: "bg-surface-white",
+ },
+ error: {
+ true: "",
+ false: "",
+ },
+ },
+ compoundVariants: [
+ {
+ variant: "subtle",
+ error: true,
+ className: "bg-surface-red-2",
+ },
+ {
+ variant: "outline",
+ error: true,
+ className: "bg-surface-white",
+ },
+ ],
+ defaultVariants: {
+ size: "sm",
+ variant: "subtle",
+ error: false,
+ },
+});
+
+const durationIndicatorVariants = cva(
+ "rounded-l rounded-r select-none cursor-grab",
+ {
+ variants: {
+ disabled: {
+ true: "bg-surface-gray-3",
+ false: "",
+ },
+ variant: {
+ subtle: "bg-surface-gray-4",
+ outline: "bg-surface-gray-3",
+ },
+ error: {
+ true: "bg-surface-red-3",
+ false: "",
+ },
+ },
+ defaultVariants: {
+ disabled: false,
+ error: false,
+ },
+ }
+);
+
+const durationInputVariants = cva(
+ "absolute -translate-y-1/2 top-1/2 right-2.5 w-10 flex items-center justify-center tabular-nums rounded-sm focus-visible:outline-offset-1",
+ {
+ variants: {
+ size: {
+ sm: "text-sm",
+ md: "text-base",
+ },
+ disabled: {
+ true: "pointer-events-none text-ink-gray-3",
+ false: "text-ink-gray-8",
+ },
+ error: {
+ true: "focus-visible:outline-2 focus-visible:outline-red",
+ false: "focus-visible:outline-2 focus-visible:outline-default",
+ },
+ },
+ defaultVariants: {
+ size: "sm",
+ disabled: false,
+ error: false,
+ },
+ }
+);
+
+const durationSpinnerVariants = cva(
+ "absolute top-1/2 -translate-y-1/2 pointer-events-none text-ink-gray-8 size-4",
+ {
+ variants: {
+ size: {
+ sm: "right-14",
+ md: "right-15",
+ },
+ },
+ defaultVariants: {
+ size: "sm",
+ },
+ }
+);
+
+const DurationInput = ({
+ id,
+ label,
+ required = false,
+ inlineLabel,
+ maxDuration = 8,
+ hoursLeft = 8,
+ value = 0,
+ snap = "step",
+ size = "sm",
+ variant = "subtle",
+ disabled = false,
+ loading = false,
+ error = false,
+ allowOverflow = false,
+ className,
+ classNames = {},
+ onChange,
+}: DurationInputProps) => {
+ const generatedId = useId();
+ const sliderId = id ?? generatedId;
+ const [draftValue, setDraftValue] = useState(null);
+ const [dragValue, setDragValue] = useState(null);
+
+ const maxDurationInHours = Math.max(maxDuration, 0);
+ const maxDurationInMinutes = maxDurationInHours * 60;
+ const hoursLeftValue = hoursLeft;
+ const committedHours = normalizeCommittedHours(
+ value,
+ maxDurationInHours,
+ allowOverflow
+ );
+ const sliderHours = clampHours(committedHours, maxDurationInHours);
+ const committedMinutes = sliderHours * 60;
+ const isSmoothSnap = snap === "smooth";
+ const sliderVal = dragValue ?? committedMinutes;
+ const previewMinutes = getPreviewMinutes(sliderVal, snap);
+ const previewHours =
+ dragValue !== null ? previewMinutes / 60 : committedHours;
+ const inputVal =
+ draftValue ??
+ (dragValue !== null ? floatToTime(previewHours) : floatToTime(value));
+ const hoursBalance = hoursLeftValue - previewHours;
+ const isOverHours = hoursBalance < 0;
+ const accessibleLabel =
+ (typeof label === "string" && label) || inlineLabel || "Duration";
+
+ const { isDragging, setIsDragging, notchOffsets } = useDurationSlider({
+ maxDuration: maxDurationInMinutes,
+ sliderStepInMins: SLIDER_STEP_MINUTES,
+ });
+
+ const handleSliderChange = useCallback(
+ (nextValue: SliderValue) => {
+ const nextMinutes = getSliderMinutes(nextValue);
+
+ if (isSmoothSnap && isDragging) {
+ setDragValue(nextMinutes);
+ return;
+ }
+
+ onChange(nextMinutes / 60);
+ },
+ [isDragging, isSmoothSnap, onChange]
+ );
+
+ const commitSliderValue = useCallback(
+ (minutes: number) => {
+ const nextValue = getSliderHours(minutes, snap, maxDurationInHours);
+
+ setDragValue(null);
+
+ if (nextValue !== value) {
+ onChange(nextValue);
+ }
+ },
+ [maxDurationInHours, onChange, snap, value]
+ );
+
+ const commitInputValue = useCallback(() => {
+ const nextValue = normalizeCommittedHours(
+ timeToFloat(draftValue ?? floatToTime(value)),
+ maxDurationInHours,
+ allowOverflow
+ );
+
+ setDraftValue(null);
+ setDragValue(null);
+
+ if (nextValue !== value) {
+ onChange(nextValue);
+ }
+ }, [allowOverflow, draftValue, value, maxDurationInHours, onChange]);
+
+ const resetInputDraft = useCallback(() => {
+ setDraftValue(null);
+ setDragValue(null);
+ }, []);
+
+ return (
+ {
+ if (isSmoothSnap && dragValue !== null) {
+ commitSliderValue(dragValue);
+ }
+
+ setIsDragging(false);
+ }}
+ disabled={disabled}
+ className={cn(
+ "space-y-1.5 relative",
+ disabled && "opacity-90 pointer-events-none",
+ className,
+ classNames.root
+ )}
+ >
+ {label ? (
+
+
+ {label}
+ {required && (
+ <>
+
+ {" "}
+ *
+
+ (required)
+ >
+ )}
+
+
+ {isOverHours
+ ? `${formatHoursBalance(Math.abs(hoursBalance))} over`
+ : `${formatHoursBalance(hoursBalance)} left`}
+
+
+ ) : null}
+
+
setIsDragging(true)}
+ >
+
+ {!disabled &&
+ isDragging &&
+ notchOffsets.map((pos) => (
+
+ ))}
+
+
+
+
+ {inlineLabel ? (
+
+ {inlineLabel}
+
+ ) : null}
+
+
+ {loading ? (
+
+ ) : null}
+
{
+ setDraftValue(floatToTime(value));
+ setDragValue(null);
+ e.currentTarget.select();
+ }}
+ onChange={(e) => {
+ setDraftValue(sanitizeHoursInput(e.target.value));
+ }}
+ onBlur={commitInputValue}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.currentTarget.blur();
+ }
+
+ if (e.key === "Escape") {
+ e.preventDefault();
+ resetInputDraft();
+ }
+ }}
+ />
+
+
+ );
+};
+
+export default DurationInput;
diff --git a/packages/frappe-ui-react/src/components/durationInput/index.ts b/packages/frappe-ui-react/src/components/durationInput/index.ts
new file mode 100644
index 00000000..542ae4db
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/durationInput/index.ts
@@ -0,0 +1,2 @@
+export { default as DurationInput } from "./durationInput";
+export * from "./types";
diff --git a/packages/frappe-ui-react/src/components/durationInput/types.ts b/packages/frappe-ui-react/src/components/durationInput/types.ts
new file mode 100644
index 00000000..581c508f
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/durationInput/types.ts
@@ -0,0 +1,65 @@
+export type DurationInputSnapMode = "step" | "smooth";
+export type DurationInputSize = "sm" | "md";
+export type DurationInputVariant = "subtle" | "outline";
+
+export interface DurationInputClassNames {
+ /** Class for the root wrapper. */
+ root?: string;
+ /** Class for the top header row that holds the label and hours-left text. */
+ header?: string;
+ /** Class for the top label element. */
+ label?: string;
+ /** Class for the hours-left or over-state text. */
+ hoursLeft?: string;
+ /** Class for the slider control wrapper. */
+ control?: string;
+ /** Class for the slider track. */
+ track?: string;
+ /** Class for the filled slider indicator. */
+ indicator?: string;
+ /** Class for the slider thumb. */
+ thumb?: string;
+ /** Class for the inline label shown inside the track. */
+ inlineLabel?: string;
+ /** Class for the loading spinner. */
+ spinner?: string;
+ /** Class for the text input. */
+ input?: string;
+}
+
+export interface DurationInputProps {
+ /** Overrides the generated id used to link an external label to the input. */
+ id?: string;
+ /** Label displayed above the input. */
+ label?: string | false;
+ /** If true, appends a required-field asterisk to the label. */
+ required?: boolean;
+ /** Label displayed inside the input. */
+ inlineLabel?: string;
+ /** Maximum allowed duration in hours. */
+ maxDuration?: number;
+ /** Remaining time used to calculate left or over state, in hours. */
+ hoursLeft?: number;
+ /** Controls whether the slider snaps continuously or moves smoothly. */
+ snap?: DurationInputSnapMode;
+ /** Height of the duration input. */
+ size?: DurationInputSize;
+ /** Visual style of the duration input. */
+ variant?: DurationInputVariant;
+ /** If true, disables the slider and text input. */
+ disabled?: boolean;
+ /** If true, shows a spinner before the input value. */
+ loading?: boolean;
+ /** If true, applies error styling. */
+ error?: boolean;
+ /** Allows manual text input to exceed maxDuration while keeping the slider capped. */
+ allowOverflow?: boolean;
+ /** Class applied to the root wrapper. */
+ className?: string;
+ /** Per-slot class overrides for the duration input internals. */
+ classNames?: DurationInputClassNames;
+ /** Current duration value in hours. */
+ value: number;
+ /** Callback fired with the new duration value in hours. */
+ onChange: (value: number) => void;
+}
diff --git a/packages/frappe-ui-react/src/components/durationInput/useDurationSlider.ts b/packages/frappe-ui-react/src/components/durationInput/useDurationSlider.ts
new file mode 100644
index 00000000..b1d5fad5
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/durationInput/useDurationSlider.ts
@@ -0,0 +1,54 @@
+import { useMemo, useState } from "react";
+
+/**
+ * Hook parameters for duration slider
+ */
+interface UseDurationSliderParams {
+ /** Maximum duration allowed in minutes */
+ maxDuration: number;
+ /** Slider step size in minutes */
+ sliderStepInMins: number;
+}
+
+/**
+ * Hook return value
+ */
+interface UseDurationSliderReturn {
+ isDragging: boolean;
+ setIsDragging: (value: boolean) => void;
+ notchOffsets: number[];
+}
+
+/**
+ * Hook for managing duration slider state and calculations
+ * @param params - Configuration object
+ * @param params.maxDuration - Maximum selectable duration in minutes
+ * @param params.sliderStepInMins - Step increment for slider in minutes
+ * @returns Object with interaction state and notch offsets
+ *
+ * @example
+ * const { notchOffsets, isDragging, setIsDragging } = useDurationSlider({
+ * maxDuration: 120,
+ * sliderStepInMins: 5,
+ * });
+ */
+export const useDurationSlider = ({
+ maxDuration,
+ sliderStepInMins,
+}: UseDurationSliderParams): UseDurationSliderReturn => {
+ const [isDragging, setIsDragging] = useState(false);
+
+ const notchOffsets = useMemo(() => {
+ const totalSteps = Math.floor(maxDuration / sliderStepInMins);
+ return Array.from(
+ { length: totalSteps - 1 },
+ (_, i) => ((i + 1) / totalSteps) * 100 - 0.5 // Adjust for visual alignment
+ );
+ }, [maxDuration, sliderStepInMins]);
+
+ return {
+ isDragging,
+ setIsDragging,
+ notchOffsets,
+ };
+};
diff --git a/packages/frappe-ui-react/src/components/durationInput/utils.ts b/packages/frappe-ui-react/src/components/durationInput/utils.ts
new file mode 100644
index 00000000..2dda2aa2
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/durationInput/utils.ts
@@ -0,0 +1,199 @@
+import type { DurationInputSnapMode } from "./types";
+
+export const SLIDER_STEP_MINUTES = 30;
+
+/**
+ * Converts decimal hours to `HH:MM`.
+ *
+ * @param float - Duration in hours.
+ * @param hourPadding - Number of digits to pad hours to.
+ * @param minutePadding - Number of digits to pad minutes to.
+ * @returns Duration formatted as `HH:MM`.
+ */
+export function floatToTime(
+ float: number,
+ hourPadding: number = 2,
+ minutePadding: number = 2
+) {
+ const totalMinutes = Math.round(float * 60);
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+
+ const formattedHours = String(hours).padStart(hourPadding, "0");
+ const formattedMinutes = String(minutes).padStart(minutePadding, "0");
+
+ return `${formattedHours}:${formattedMinutes}`;
+}
+
+/**
+ * Converts a duration string to decimal hours.
+ * Accepts decimal hours or `HH:MM`-style duration input.
+ *
+ * @param value - Duration string to parse.
+ * @returns Duration in hours.
+ */
+export const timeToFloat = (value: string): number => {
+ const trimmedValue = value.trim();
+
+ if (/^(\d+(\.\d*)?|\.\d+)$/.test(trimmedValue)) {
+ return Number(trimmedValue);
+ }
+
+ const hhmmMatch = /^([0-9]*):([0-9]*)$/.exec(trimmedValue);
+
+ if (!hhmmMatch || (!hhmmMatch[1] && !hhmmMatch[2])) {
+ return 0;
+ }
+
+ const hours = Number(hhmmMatch[1] || 0);
+ const minutes = Number(hhmmMatch[2] || 0);
+
+ if (Number.isNaN(hours) || Number.isNaN(minutes)) {
+ return 0;
+ }
+
+ return hours + minutes / 60;
+};
+
+/**
+ * Clamps hours between `0` and the configured maximum.
+ *
+ * @param hours - Duration in hours.
+ * @param maxDurationInHours - Maximum allowed duration in hours.
+ * @returns Clamped duration in hours.
+ */
+export const clampHours = (
+ hours: number,
+ maxDurationInHours: number
+): number => {
+ return Math.min(Math.max(hours, 0), maxDurationInHours);
+};
+
+/**
+ * Rounds slider minutes to the nearest slider step.
+ *
+ * @param minutes - Slider value in minutes.
+ * @returns Snapped slider value in minutes.
+ */
+export const snapToSliderStep = (minutes: number): number => {
+ return Math.round(minutes / SLIDER_STEP_MINUTES) * SLIDER_STEP_MINUTES;
+};
+
+/**
+ * Extracts the numeric slider value from the slider payload.
+ *
+ * @param sliderValue - Scalar or array slider payload.
+ * @returns Slider value in minutes.
+ */
+export const getSliderMinutes = (
+ sliderValue: number | readonly number[]
+): number => {
+ if (typeof sliderValue === "number") {
+ return sliderValue;
+ }
+
+ return sliderValue[0] ?? 0;
+};
+
+/**
+ * Returns the slider preview value in minutes.
+ *
+ * @param minutes - Raw slider minutes.
+ * @param snap - Slider snap mode.
+ * @returns Preview minutes for the current slider position.
+ */
+export const getPreviewMinutes = (
+ minutes: number,
+ snap: DurationInputSnapMode
+): number => {
+ return snap === "smooth" ? snapToSliderStep(minutes) : minutes;
+};
+
+/**
+ * Converts slider minutes into normalized hours.
+ *
+ * @param minutes - Raw slider minutes.
+ * @param snap - Slider snap mode.
+ * @param maxDurationInHours - Maximum allowed duration in hours.
+ * @returns Normalized slider value in hours.
+ */
+export const getSliderHours = (
+ minutes: number,
+ snap: DurationInputSnapMode,
+ maxDurationInHours: number
+): number => {
+ return clampHours(getPreviewMinutes(minutes, snap) / 60, maxDurationInHours);
+};
+
+/**
+ * Normalizes committed hours based on overflow behavior.
+ *
+ * @param hours - Duration in hours.
+ * @param maxDurationInHours - Maximum allowed duration in hours.
+ * @param allowOverflow - Whether manual input can exceed the slider maximum.
+ * @returns Normalized duration in hours.
+ */
+export const normalizeCommittedHours = (
+ hours: number,
+ maxDurationInHours: number,
+ allowOverflow: boolean
+): number => {
+ if (allowOverflow) {
+ return Math.max(hours, 0);
+ }
+
+ return clampHours(hours, maxDurationInHours);
+};
+
+/**
+ * Filters text input down to characters valid for decimal hours or `HH:MM`.
+ *
+ * @param value - Raw input value.
+ * @returns Sanitized input string.
+ */
+export const sanitizeHoursInput = (value: string): string => {
+ const sanitizedValue = value.replace(/[^0-9:.]/g, "");
+
+ // If the input contains a colon, treat it as `HH:MM` and remove any periods.
+ if (sanitizedValue.includes(":")) {
+ const [hours = "", ...minuteParts] = sanitizedValue
+ .replace(/\./g, "")
+ .split(":");
+
+ return `${hours}:${minuteParts.join("")}`;
+ }
+
+ const [hours = "", ...decimalParts] = sanitizedValue.split(".");
+
+ if (decimalParts.length === 0) {
+ return hours;
+ }
+
+ return `${hours}.${decimalParts.join("")}`;
+};
+
+/**
+ * Formats the balance text shown above the control.
+ *
+ * @param value - Balance in hours.
+ * @returns Balance text in `Xh Ym` form (e.g. `10m`, `1h 30m`, `8h`).
+ */
+export const formatHoursBalance = (value: number): string => {
+ const totalMinutes = Math.round(value * 60);
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+
+ if (totalMinutes === 0) {
+ return "0h";
+ }
+
+ if (hours === 0) {
+ return `${minutes}m`;
+ }
+
+ if (minutes === 0) {
+ return `${hours}h`;
+ }
+
+ return `${hours}h ${minutes}m`;
+};
diff --git a/packages/frappe-ui-react/src/components/fileUploader/fileuploader.tsx b/packages/frappe-ui-react/src/components/fileUploader/fileuploader.tsx
index 8a5e8247..40ca948e 100644
--- a/packages/frappe-ui-react/src/components/fileUploader/fileuploader.tsx
+++ b/packages/frappe-ui-react/src/components/fileUploader/fileuploader.tsx
@@ -23,7 +23,7 @@ export interface FileUploaderProps {
openFileSelector: () => void;
}) => React.ReactNode;
onSuccess?: (data: UploadedFile) => void;
- onFailure?: (error: unknown) => void;
+ onFailure?: (error: unknown, errorMessage?: string) => void;
}
const FileUploader: React.FC = ({
@@ -146,7 +146,7 @@ const FileUploader: React.FC = ({
}
}
setError(errorMessage);
- onFailure?.(err);
+ onFailure?.(err, errorMessage);
});
};
diff --git a/packages/frappe-ui-react/src/components/filter/filter.stories.tsx b/packages/frappe-ui-react/src/components/filter/filter.stories.tsx
new file mode 100644
index 00000000..d6363361
--- /dev/null
+++ b/packages/frappe-ui-react/src/components/filter/filter.stories.tsx
@@ -0,0 +1,341 @@
+import * as React from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Filter } from "./index";
+import { Combobox } from "../combobox";
+import type {
+ FilterCondition,
+ FilterField,
+ FilterLinkValueRenderProps,
+} from "./types";
+import { TextInput } from "../textInput";
+
+const meta: Meta = {
+ title: "Components/Filter",
+ tags: ["autodocs"],
+ component: Filter,
+ parameters: {
+ docs: {
+ source: { type: "dynamic" },
+ story: { height: "400px" },
+ },
+ layout: "centered",
+ },
+ argTypes: {
+ fields: {
+ control: "object",
+ description: "Available fields that can be filtered",
+ },
+ value: {
+ control: "object",
+ description: "Current filter conditions",
+ },
+ onChange: {
+ action: "changed",
+ description: "Callback when filters change",
+ },
+ maxFilters: {
+ control: "number",
+ description: "Maximum number of filters allowed",
+ },
+ showCount: {
+ control: "boolean",
+ description: "Whether to show the filter count badge",
+ },
+ defaultOpen: {
+ control: "boolean",
+ description: "Whether the filter panel is open by default",
+ },
+ externalFilterCount: {
+ control: "number",
+ description:
+ "Count of filters managed outside this component (e.g. standalone quick filters) — folded into the active-filter count so 'Clear all' shows even when `value` is empty",
+ },
+ onClearAll: {
+ action: "cleared-all",
+ description:
+ "Called when the user clicks 'Clear all', in addition to onChange([]) — use to reset any externally-managed filters. Not called for row-by-row removal.",
+ },
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+const projectOptions = [
+ { label: "Atlas UI Stabilization", value: "atlas-ui" },
+ { label: "Backend Refactor", value: "backend" },
+ { label: "Mobile App v2", value: "mobile-v2" },
+ { label: "Dashboard Redesign", value: "dashboard" },
+ { label: "API Documentation", value: "api-docs" },
+];
+
+const statusOptions = [
+ { label: "Open", value: "open" },
+ { label: "In Progress", value: "in-progress" },
+ { label: "Review", value: "review" },
+ { label: "Done", value: "done" },
+ { label: "Cancelled", value: "cancelled" },
+];
+
+const priorityOptions = [
+ { label: "Urgent", value: "urgent" },
+ { label: "High", value: "high" },
+ { label: "Medium", value: "medium" },
+ { label: "Low", value: "low" },
+];
+
+const assigneeOptions = [
+ { label: "John Doe", value: "john" },
+ { label: "Jane Smith", value: "jane" },
+ { label: "Bob Wilson", value: "bob" },
+ { label: "Alice Johnson", value: "alice" },
+];
+
+const sampleFields: FilterField[] = [
+ {
+ name: "project",
+ label: "Project",
+ type: "select",
+ options: projectOptions,
+ },
+ {
+ name: "status",
+ label: "Status",
+ type: "select",
+ options: statusOptions,
+ },
+ {
+ name: "priority",
+ label: "Priority",
+ type: "select",
+ options: priorityOptions,
+ },
+ {
+ name: "assignee",
+ label: "Assignee",
+ type: "select",
+ options: assigneeOptions,
+ },
+ {
+ name: "customer_link",
+ label: "Customer (Link)",
+ type: "link",
+ link: { doctype: "User", labelField: "full_name" },
+ },
+ {
+ name: "title",
+ label: "Title",
+ type: "string",
+ },
+ {
+ name: "due_date",
+ label: "Due Date",
+ type: "date",
+ },
+ {
+ name: "estimate",
+ label: "Estimate (hours)",
+ type: "number",
+ },
+ {
+ name: "date_range",
+ label: "Date Range",
+ type: "daterange",
+ },
+];
+
+// Controlled component wrapper
+const FilterWithState = (props: React.ComponentProps) => {
+ const [filters, setFilters] = React.useState(
+ props.value || []
+ );
+
+ return (
+ {
+ setFilters(newFilters);
+ props.onChange?.(newFilters);
+ }}
+ />
+ );
+};
+
+export const Default: Story = {
+ render: (args) => ,
+ args: {
+ fields: sampleFields,
+ },
+};
+
+export const WithInitialFilters: Story = {
+ render: (args) => ,
+ args: {
+ fields: sampleFields,
+ value: [
+ {
+ id: "1",
+ field: "project",
+ operator: "is",
+ value: "atlas-ui",
+ },
+ ],
+ },
+};
+
+export const MultipleFilters: Story = {
+ render: (args) => ,
+ args: {
+ fields: sampleFields,
+ value: [
+ {
+ id: "1",
+ field: "project",
+ operator: "is",
+ value: "atlas-ui",
+ },
+ {
+ id: "2",
+ field: "status",
+ operator: "is",
+ value: "in-progress",
+ },
+ {
+ id: "3",
+ field: "assignee",
+ operator: "is",
+ value: "john",
+ },
+ ],
+ },
+};
+
+export const WithoutCount: Story = {
+ render: (args) => ,
+ args: {
+ fields: sampleFields,
+ showCount: false,
+ value: [
+ {
+ id: "1",
+ field: "project",
+ operator: "is",
+ value: "atlas-ui",
+ },
+ ],
+ },
+};
+
+export const LimitedFilters: Story = {
+ render: (args) => ,
+ args: {
+ fields: sampleFields,
+ maxFilters: 3,
+ },
+};
+
+export const SimpleFields: Story = {
+ render: (args) => ,
+ args: {
+ fields: [
+ { name: "name", label: "Name", type: "string" },
+ { name: "email", label: "Email", type: "string" },
+ { name: "age", label: "Age", type: "number" },
+ ],
+ },
+};
+
+export const DefaultOpen: Story = {
+ render: (args) => ,
+ args: {
+ fields: sampleFields,
+ defaultOpen: true,
+ },
+};
+
+const WithExternalFilterState = (args: React.ComponentProps) => {
+ const [search, setSearch] = React.useState("");
+ const [filters, setFilters] = React.useState(
+ args.value || []
+ );
+
+ return (
+