2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
/* eslint-disable playwright/missing-playwright-await */
import { render, fireEvent } from "@testing-library/react";
import React from "react";
import { vi } from "vitest";
import { CheckboxField } from "./Checkbox";
const basicProps = { label: "Test Label", description: "Test Description" };
describe("Tests for CheckboxField component", () => {
test("Should render the label and the description correctly", () => {
const { getByText } = render(<CheckboxField {...basicProps} />);
const labelElement = getByText("Test Label");
expect(labelElement).toBeInTheDocument();
const descriptionElement = getByText("Test Description");
expect(descriptionElement).toBeInTheDocument();
});
test("Should render the description correctly when the prop descriptionAsLabel is true", () => {
const { getByText } = render(<CheckboxField {...basicProps} descriptionAsLabel />);
const descriptionElement = getByText("Test Label");
expect(descriptionElement).toBeInTheDocument();
});
test("Should trigger onChange event correctly", () => {
const handleChange = vi.fn();
const { getByRole } = render(<CheckboxField {...basicProps} onChange={handleChange} />);
const checkboxInput = getByRole("checkbox");
fireEvent.click(checkboxInput);
expect(handleChange).toHaveBeenCalled();
});
test("Should disable the checkbox when disabled prop is true", () => {
const { getByRole } = render(<CheckboxField {...basicProps} disabled />);
const checkboxInput = getByRole("checkbox");
expect(checkboxInput).toBeDisabled();
});
test("Should change the checked state when clicked", () => {
const { getByRole } = render(<CheckboxField {...basicProps} disabled />);
const checkboxInput = getByRole("checkbox");
expect(checkboxInput).not.toBeChecked();
expect(checkboxInput).toBeTruthy();
fireEvent.click(checkboxInput);
expect(checkboxInput).toBeChecked();
expect(checkboxInput).toBeTruthy();
fireEvent.click(checkboxInput);
expect(checkboxInput).not.toBeChecked();
});
});

View File

@@ -0,0 +1,115 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { useId } from "@radix-ui/react-id";
import type { InputHTMLAttributes } from "react";
import React, { forwardRef } from "react";
import classNames from "@calcom/lib/classNames";
import { Icon } from "@calcom/ui";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label?: React.ReactNode;
description: string;
descriptionAsLabel?: boolean;
informationIconText?: string;
error?: boolean;
className?: string;
descriptionClassName?: string;
/**
* Accepts this special property instead of allowing description itself to be accidentally used in dangerous way.
*/
descriptionAsSafeHtml?: string;
};
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={classNames(
"border-default data-[state=checked]:bg-brand-default data-[state=checked]:text-brand peer h-4 w-4 shrink-0 rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={classNames("flex items-center justify-center text-current")}>
<Icon name="check" className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
const CheckboxField = forwardRef<HTMLInputElement, Props>(
({ label, description, error, disabled, descriptionAsSafeHtml, ...rest }, ref) => {
const descriptionAsLabel = !label || rest.descriptionAsLabel;
const id = useId();
return (
<div className="block items-center sm:flex">
{label && (
<div className="min-w-48 mb-4 sm:mb-0">
{React.createElement(
descriptionAsLabel ? "div" : "label",
{
className: classNames("flex text-sm font-medium text-emphasis"),
...(!descriptionAsLabel
? {
htmlFor: rest.id ? rest.id : id,
}
: {}),
},
label
)}
</div>
)}
<div className="w-full">
<div className="relative flex items-center">
{React.createElement(
descriptionAsLabel ? "label" : "div",
{
className: classNames(
"relative flex items-start",
!error && descriptionAsLabel ? "text-emphasis" : "text-emphasis",
error && "text-error"
),
},
<>
<div className="flex h-5 items-center">
<input
{...rest}
ref={ref}
type="checkbox"
disabled={disabled}
id={rest.id ? rest.id : id}
className={classNames(
"text-emphasis focus:ring-emphasis dark:text-muted border-default bg-default focus:bg-default active:bg-default h-4 w-4 rounded checked:hover:bg-gray-600 focus:outline-none focus:ring-0 ltr:mr-2 rtl:ml-2",
!error && disabled
? "cursor-not-allowed bg-gray-300 checked:bg-gray-300 hover:bg-gray-300 hover:checked:bg-gray-300"
: "hover:bg-subtle hover:border-emphasis checked:bg-gray-800",
error &&
"border-error hover:bg-error hover:border-error checked:bg-darkerror checked:hover:border-error checked:hover:bg-darkerror",
rest.className
)}
/>
</div>
{descriptionAsSafeHtml ? (
<span
className={classNames("text-sm", rest.descriptionClassName)}
dangerouslySetInnerHTML={{
__html: descriptionAsSafeHtml,
}}
/>
) : (
<span className={classNames("text-sm", rest.descriptionClassName)}>{description}</span>
)}
</>
)}
{/* {informationIconText && <InfoBadge content={informationIconText}></InfoBadge>} */}
</div>
</div>
</div>
);
}
);
CheckboxField.displayName = "CheckboxField";
export { Checkbox, CheckboxField };

View File

@@ -0,0 +1,143 @@
import type { Dispatch, SetStateAction } from "react";
import React from "react";
import type {
GroupBase,
OptionProps,
MultiValueProps,
MultiValue as MultiValueType,
SingleValue,
} from "react-select";
import { components } from "react-select";
import type { Props } from "react-select";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Select } from "../select";
export type Option = {
value: string;
label: string;
};
const InputOption: React.FC<OptionProps<Option, boolean, GroupBase<Option>>> = ({
isDisabled,
isFocused,
isSelected,
children,
innerProps,
...rest
}) => {
const props = {
...innerProps,
};
return (
<components.Option
{...rest}
isDisabled={isDisabled}
isFocused={isFocused}
isSelected={isSelected}
innerProps={props}>
<input
type="checkbox"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default h-4 w-4 rounded ltr:mr-2 rtl:ml-2"
checked={isSelected}
readOnly
/>
{children}
</components.Option>
);
};
type MultiSelectionCheckboxesProps = {
options: { label: string; value: string }[];
setSelected: Dispatch<SetStateAction<Option[]>>;
selected: Option[];
setValue: (s: Option[]) => unknown;
countText?: string;
};
const MultiValue = ({
index,
getValue,
countText,
}: {
index: number;
getValue: () => readonly Option[];
countText: string;
}) => {
const { t } = useLocale();
const count = getValue().filter((option) => option.value !== "all").length;
return <>{!index && count !== 0 && <div>{t(countText, { count })}</div>}</>;
};
export default function MultiSelectCheckboxes({
options,
isLoading,
selected,
setSelected,
setValue,
className,
isDisabled,
countText,
}: Omit<Props, "options"> & MultiSelectionCheckboxesProps) {
const additonalComponents = {
MultiValue: (props: MultiValueProps<Option, boolean, GroupBase<Option>>) => (
<MultiValue {...props} countText={countText || "selected"} />
),
};
const allOptions = [{ label: "Select all", value: "all" }, ...options];
const allSelected = selected.length === options.length ? allOptions : selected;
return (
<Select
value={allSelected}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(s: MultiValueType<Option> | SingleValue<Option>, event: any) => {
const allSelected = [];
if (s !== null && Array.isArray(s) && s.length > 0) {
if (s.find((option) => option.value === "all")) {
if (event.action === "select-option") {
allSelected.push(...[{ label: "Select all", value: "all" }, ...options]);
} else {
allSelected.push(...s.filter((option) => option.value !== "all"));
}
} else {
if (s.length === options.length) {
if (s.find((option) => option.value === "all")) {
allSelected.push(...s.filter((option) => option.value !== "all"));
} else {
if (event.action === "select-option") {
allSelected.push(...[...s, { label: "Select all", value: "all" }]);
}
}
} else {
allSelected.push(...s);
}
}
}
setSelected(allSelected);
setValue(allSelected);
}}
variant="checkbox"
options={allOptions.length > 1 ? allOptions : []}
isMulti
isDisabled={isDisabled}
className={classNames(className ? className : "w-64 text-sm")}
isSearchable={true}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isLoading={isLoading}
data-testid="multi-select-check-boxes"
components={{
...additonalComponents,
Option: InputOption,
}}
/>
);
}

View File

@@ -0,0 +1,99 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { CheckboxField as Checkbox } from "./Checkbox";
<Meta title="UI/Form/Checkbox" component={Checkbox} />
<Title title="Checkbox " suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
## Definition
Checkboxes are used in forms and databases to indicate an answer to a question, apply a batch of settings or allow the user to make a multi-selection from a list. Alternatively, a single checkbox may be used for making single selections
## Structure
<CustomArgsTable of={Checkbox} />
<Examples title="Checkbox style">
<Example title="Default">
<Checkbox label="Default" />
</Example>
<Example title="Error">
<Checkbox label="Error" error />
</Example>
<Example title="Disabled">
<Checkbox label="Disabled" disabled />
</Example>
<Example title="Disabled">
<Checkbox label="Disabled Checked" checked disabled />
</Example>
</Examples>
<Examples title="Description As Label">
<Example title="Default">
<Checkbox descriptionAsLabel description="Default Description" />
</Example>
<Example title="Error">
<Checkbox descriptionAsLabel description="Default Description" error />
</Example>
<Example title="Disabled">
<Checkbox descriptionAsLabel description="Default Description" disabled />
</Example>
<Example title="Disabled">
<Checkbox descriptionAsLabel description="Default Description" disabled checked />
</Example>
</Examples>
<Canvas>
<Story
name="Checkbox"
args={{
label: "Default",
description: "Default Description",
error: false,
disabled: false,
}}
argTypes={{
label: {
control: {
type: "text",
},
},
description: {
control: {
type: "text",
},
},
error: {
control: {
type: "boolean",
},
},
disabled: {
control: {
type: "boolean",
},
},
}}>
{({ label, description, error, disabled }) => (
<VariantsTable titles={[""]} columnMinWidth={150}>
<VariantRow variant="Default">
<Checkbox label={label} error={error} disabled={disabled} />
</VariantRow>
<VariantRow variant="Description As Label">
<Checkbox description={description} error={error} disabled={disabled} descriptionAsLabel />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,3 @@
export { Checkbox, CheckboxField } from "./Checkbox";
export { default as MultiSelectCheckbox } from "./MultiSelectCheckboxes";
export type { Option } from "./MultiSelectCheckboxes";

View File

@@ -0,0 +1,82 @@
import { Tooltip } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
Example,
Title,
CustomArgsTable,
VariantRow,
VariantsTable,
} from "@calcom/storybook/components";
import ColorPicker from "./colorpicker";
<Meta title="UI/Form/ColorPicker" component={ColorPicker} />
<Title title="ColorPicker" suffix="Brief" subtitle="Version 1.0 — Last Update: 16 Aug 2023" />
## Definitions
`Color Picker` is used to select custom hex colors for from a range of values.
## Structure
The `Color Picker` takes in several props
<CustomArgsTable of={ColorPicker} />
## Default:
<Example title="Default">
<ColorPicker defaultValue="#000000" />
</Example>
## ColorPicker Story
<Canvas>
<Story
name="Default"
args={{
defaultValue: "#21aef3",
onChange: (value) => {
console.debug(value);
},
resetDefaultValue: "#000000",
className: "w-[200px]",
popoverAlign: "start",
}}
argTypes={{
defaultValue: {
control: {
type: "text",
},
},
resetDefaultValue: {
control: {
type: "text",
},
},
popoverAlign: {
control: {
type: "inline-radio",
options: ["center", "start", "end"],
},
},
}}>
{({ defaultValue, onChange, resetDefaultValue, className, popoverAlign }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<Tooltip content="color picker">
<ColorPicker
defaultValue={defaultValue}
onChange={onChange}
resetDefaultValue={resetDefaultValue}
className={className}
popoverAlign={popoverAlign}
/>
</Tooltip>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,114 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { act } from "react-dom/test-utils";
import { vi } from "vitest";
import type { ButtonProps } from "../../button";
import { Button } from "../../button";
import ColorPicker from "./colorpicker";
vi.mock("@calcom/ui", async () => {
return {
Icon: () => <svg data-testid="dummy-icon" />,
Button: ({ tooltip, ...rest }: ButtonProps) => <Button {...rest}>{tooltip}</Button>,
};
});
describe("Tests for ColorPicker component", () => {
test("Should render the color picker with a given default value", () => {
const defaultValue = "#FF0000";
const onChange = vi.fn();
render(<ColorPicker defaultValue={defaultValue} onChange={onChange} />);
const colorPickerButton = screen.getByRole("button", { name: "pick colors" });
expect(colorPickerButton).toHaveStyle(`background-color: ${defaultValue}`);
});
test("Should select a new color using the color picker", async () => {
const defaultValue = "#FF0000";
const onChange = vi.fn();
render(<ColorPicker defaultValue={defaultValue} onChange={onChange} />);
const colorPickerButton = screen.getByRole("button", { name: "pick colors" });
await act(async () => {
fireEvent.click(colorPickerButton);
});
const colorPickerInput = screen.getByRole("textbox");
await act(async () => {
fireEvent.change(colorPickerInput, { target: { value: "#000000" } });
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(colorPickerButton).toHaveStyle("background-color: #000000");
});
test("Should change the color value using the input field", async () => {
const onChange = vi.fn();
const defaultValue = "#FF0000";
render(<ColorPicker defaultValue={defaultValue} onChange={onChange} />);
const colorInput = screen.getByRole("textbox");
await act(async () => userEvent.clear(colorInput));
const newColorValue = "#00FF00";
await act(async () => await userEvent.type(colorInput, newColorValue));
expect(screen.getByRole("button", { name: "pick colors" })).toHaveStyle(
`background-color: ${newColorValue}`
);
});
test("Should not change the color value when an invalid HEX value is entered", async () => {
const defaultValue = "#FF0000";
const onChange = vi.fn();
render(<ColorPicker defaultValue={defaultValue} onChange={onChange} />);
const colorPickerButton = screen.getByRole("button", { name: "pick colors" });
await act(async () => {
fireEvent.click(colorPickerButton);
});
const colorPickerInput = screen.getByRole("textbox");
await act(async () => {
fireEvent.change(colorPickerInput, { target: { value: "#FF0000240" } });
});
expect(colorPickerButton).toHaveStyle(`background-color: ${defaultValue}`);
});
test("Should reset the color to default when clicking on the reset button", async () => {
const defaultValue = "#FF0000";
const resetDefaultValue = "#00FF00";
const onChange = vi.fn();
render(
<ColorPicker defaultValue={defaultValue} resetDefaultValue={resetDefaultValue} onChange={onChange} />
);
const colorPickerButton = screen.getByRole("button", { name: "pick colors" });
await act(async () => {
fireEvent.click(colorPickerButton);
});
const colorPickerInput = screen.getByRole("textbox");
await act(async () => {
fireEvent.change(colorPickerInput, { target: { value: "#000000" } });
});
const resetButton = screen.getByRole("button", { name: "Reset to default" });
await act(async () => {
fireEvent.click(resetButton);
});
expect(colorPickerButton).toHaveStyle(`background-color: ${resetDefaultValue}`);
expect(onChange).toHaveBeenCalledWith(resetDefaultValue);
});
test("Should not show the reset button when resetDefaultValue prop is not provided", async () => {
const defaultValue = "#FF0000";
const onChange = vi.fn();
render(<ColorPicker defaultValue={defaultValue} onChange={onChange} />);
const resetButton = screen.queryByRole("button", { name: "Reset to default" });
expect(resetButton).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,87 @@
import * as Popover from "@radix-ui/react-popover";
import { useState } from "react";
import { HexColorInput, HexColorPicker } from "react-colorful";
import cx from "@calcom/lib/classNames";
import { fallBackHex, isValidHexCode } from "@calcom/lib/getBrandColours";
import { Button } from "@calcom/ui";
export type ColorPickerProps = {
defaultValue: string;
onChange: (text: string) => void;
container?: HTMLElement;
popoverAlign?: React.ComponentProps<typeof Popover.Content>["align"];
className?: string;
resetDefaultValue?: string;
};
const ColorPicker = (props: ColorPickerProps) => {
const init = !isValidHexCode(props.defaultValue)
? fallBackHex(props.defaultValue, false)
: props.defaultValue;
const resetDefaultValue =
props.resetDefaultValue &&
(!isValidHexCode(props.resetDefaultValue)
? fallBackHex(props.resetDefaultValue, false)
: props.resetDefaultValue);
const [color, setColor] = useState(init);
return (
<div className="mt-1 flex h-[38px] items-center justify-center">
<Popover.Root>
<div className="border-default min-w-9 flex h-full items-center justify-center border ltr:rounded-l-md ltr:border-r-0 rtl:rounded-r-md rtl:border-l-0">
<Popover.Trigger asChild>
<button
className="h-5 w-5 rounded-sm"
aria-label="pick colors"
style={{ backgroundColor: color }}
/>
</Popover.Trigger>
</div>
<Popover.Portal container={props.container}>
<Popover.Content align={props.popoverAlign ?? "center"} sideOffset={10}>
<HexColorPicker
color={color}
className="!h-32 !w-32"
onChange={(val) => {
setColor(val);
props.onChange(val);
}}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<HexColorInput
className={cx(
"border-default text-default bg-default block h-full w-full border px-3 py-2 ltr:rounded-r-md rtl:rounded-l-md sm:text-sm",
props.className
)}
color={color}
onChange={(val) => {
setColor(val);
props.onChange(val);
}}
type="text"
/>
{resetDefaultValue && color != resetDefaultValue && (
<div className="px-1">
<Button
color={resetDefaultValue == "#292929" ? "primary" : "secondary"}
target="_blank"
variant="icon"
rel="noreferrer"
StartIcon="rotate-ccw"
tooltip="Reset to default"
onClick={() => {
setColor(fallBackHex(resetDefaultValue, false));
props.onChange(resetDefaultValue);
}}
/>
</div>
)}
</div>
);
};
export default ColorPicker;

View File

@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import { DayPicker } from "react-day-picker";
import dayjs from "@calcom/dayjs";
import { classNames as cn } from "@calcom/lib";
import { Icon } from "../../../index";
import { buttonClasses } from "../../button/Button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
fromDate={new Date()}
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex pt-1 relative items-center justify-between",
caption_label: "text-sm font-medium",
nav: "flex items-center",
head: "",
head_row: "flex w-full items-center justify-between",
head_cell: "w-8 md:w-11 h-8 text-sm font-medium text-default",
nav_button: cn(buttonClasses({ color: "minimal", variant: "icon" })),
table: "w-full border-collapse space-y-1",
row: "flex w-full mt-2 gap-0.5",
cell: "h-8 w-8 md:h-11 md:w-11 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonClasses({ color: "minimal" }),
"w-8 h-8 md:h-11 md:w-11 p-0 text-sm font-medium aria-selected:opacity-100 inline-flex items-center justify-center"
),
day_range_end: "day-range-end",
day_selected: "bg-inverted text-inverted",
day_today: "",
day_outside: "",
day_disabled: "text-muted opacity-50",
day_range_middle: "aria-selected:bg-emphasis aria-selected:text-emphasis",
day_hidden: "invisible",
...classNames,
}}
components={{
CaptionLabel: (capLabelProps) => (
<div className="px-2">
<span className="text-emphasis leadning-none font-semibold">
{dayjs(capLabelProps.displayMonth).format("MMMM")}{" "}
</span>
<span className="text-subtle font-medium leading-none">
{dayjs(capLabelProps.displayMonth).format("YYYY")}
</span>
</div>
),
IconLeft: () => <Icon name="chevron-left" className="h-4 w-4 stroke-2" />,
IconRight: () => <Icon name="chevron-right" className="h-4 w-4 stroke-2" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1,70 @@
"use client";
import { format } from "date-fns";
import * as React from "react";
import type { DateRange } from "react-day-picker";
import { classNames as cn } from "@calcom/lib";
import { Button } from "../../button";
import { Popover, PopoverContent, PopoverTrigger } from "../../popover";
import { Calendar } from "./Calendar";
type DatePickerWithRangeProps = {
dates: { startDate: Date; endDate?: Date };
onDatesChange: ({ startDate, endDate }: { startDate?: Date; endDate?: Date }) => void;
disabled?: boolean;
};
export function DatePickerWithRange({
className,
dates,
onDatesChange,
disabled,
}: React.HTMLAttributes<HTMLDivElement> & DatePickerWithRangeProps) {
// Even though this is uncontrolled we need to do a bit of logic to improve the UX when selecting dates
function _onDatesChange(onChangeValues: DateRange | undefined) {
if (onChangeValues?.from && !onChangeValues?.to) {
onDatesChange({ startDate: onChangeValues.from, endDate: onChangeValues.from });
} else {
onDatesChange({ startDate: onChangeValues?.from, endDate: onChangeValues?.to });
}
}
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
color="secondary"
EndIcon="calendar"
className={cn("justify-between text-left font-normal", !dates && "text-subtle")}>
{dates?.startDate ? (
dates?.endDate ? (
<>
{format(dates.startDate, "LLL dd, y")} - {format(dates.endDate, "LLL dd, y")}
</>
) : (
format(dates.startDate, "LLL dd, y")
)
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={dates?.startDate}
selected={{ from: dates?.startDate, to: dates?.endDate }}
onSelect={(values) => _onDatesChange(values)}
numberOfMonths={1}
disabled={disabled}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import dynamic from "next/dynamic";
export const DateRangePickerLazy = dynamic(() =>
import("./DateRangePicker").then((mod) => mod.DatePickerWithRange)
);

View File

@@ -0,0 +1,265 @@
.react-calendar {
width: 350px;
max-width: 100%;
background: var(--cal-bg);
border: 1px solid var(--cal-bg-inverted);
font-family: Arial, Helvetica, sans-serif;
line-height: 1.125em;
}
.react-calendar,
.react-calendar *,
.react-calendar ::before,
.react-calendar ::after {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.react-calendar--doubleView {
width: 700px;
}
.react-calendar--doubleView .react-calendar__viewContainer {
display: flex;
margin: -0.5em;
}
.react-calendar--doubleView .react-calendar__viewContainer > * {
width: 50%;
margin: 0.5em;
}
.react-calendar button {
margin: 0;
border: 0;
outline: none;
}
.react-calendar__navigation {
display: flex;
height: 44px;
margin-bottom: 1em;
}
.react-calendar__navigation button {
min-width: 44px;
background: none;
}
.react-calendar button:enabled:hover {
cursor: pointer;
}
.react-calendar__navigation button:disabled {
background-color: transparent;
}
.react-calendar__navigation button:enabled:hover,
.react-calendar__navigation button:enabled:focus {
background-color: #1d5ad8;
color: var(--cal-text-emphasis);
}
.react-calendar__tile {
max-width: 100%;
padding: 10px 6.6667px;
background: none;
text-align: center;
line-height: 16px;
}
.react-calendar__month-view__weekdays {
text-align: center;
text-transform: uppercase;
font-weight: bold;
font-size: 0.75em;
}
.react-calendar__month-view__weekdays__weekday {
padding: 0.5em;
}
.react-calendar__month-view__weekNumbers .react-calendar__tile {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75em;
font-weight: bold;
}
.react-calendar__year-view .react-calendar__tile,
.react-calendar__decade-view .react-calendar__tile,
.react-calendar__century-view .react-calendar__tile {
padding: 2em 0.5em;
}
.react-calendar__tile:disabled {
background-color: #f0f0f0;
}
.react-calendar__tile:enabled:hover,
.react-calendar__tile:enabled:focus {
background-color: var(--cal-bg-subtle);
}
.react-daterange-picker {
width: 100%;
}
.react-daterange-picker > .react-daterange-picker__wrapper {
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
border-radius: 0.375rem;
width: 100%;
border-color: var(--cal-border);
display: flex;
flex-direction: row;
align-items: center;
height: 36px;
}
.react-daterange-picker__wrapper > .react-daterange-picker__calendar-button.react-daterange-picker__button {
padding: 4px 0;
}
.react-daterange-picker > .react-daterange-picker__wrapper > .react-daterange-picker__inputGroup {
height: auto;
}
.react-daterange-picker.react-daterange-picker--disabled {
background-color: transparent;
}
.react-daterange-picker.react-daterange-picker--disabled > .react-daterange-picker__wrapper {
background-color: #f3f4f6;
}
.react-daterange-picker > .react-daterange-picker__wrapper:focus-within,
.react-daterange-picker > .react-daterange-picker__wrapper:focus-within:hover {
border-color: var(--cal-bg-subtle);
border-width: 2px;
}
.react-daterange-picker > .react-daterange-picker__wrapper input {
margin: 0;
height: auto;
border-radius: 0.125rem;
}
.react-daterange-picker__calendar.react-daterange-picker__calendar--open {
width: 360px;
}
.react-daterange-picker__calendar > .react-calendar {
padding: 0.5rem;
border-radius: 0.375rem;
border-width: 1px;
border-color: var(--cal-border);
width: 360px;
margin-top: 4px;
}
.react-calendar__navigation > .react-calendar__navigation__arrow {
display: flex;
justify-content: center;
align-items: center;
border-radius: 6px;
}
.react-calendar__navigation > .react-calendar__navigation__arrow.react-calendar__navigation__prev2-button,
.react-calendar__navigation > .react-calendar__navigation__arrow.react-calendar__navigation__next2-button {
display: none;
}
.react-calendar__navigation > .react-calendar__navigation__label {
display: flex;
padding-left: 0.5rem;
padding-right: 0.5rem;
color: var(--cal-text);
order: -9999;
justify-content: flex-start;
align-items: center;
font-size: 16px;
}
.react-calendar__navigation > .react-calendar__navigation__arrow {
text-align: center;
color: #6b7280;
}
.react-calendar__month-view__weekdays__weekday > abbr {
font-weight: 500;
text-decoration: none;
font-size: 12px;
}
.react-calendar__month-view__days {
padding: 0.25rem;
gap: 0.25rem;
font-size: 14px;
}
button.react-calendar__tile.react-calendar__month-view__days__day {
flex: 0 0 13.25% !important;
border-radius: 0.375rem;
padding-top: 13px;
padding-bottom: 13px;
position: relative;
}
.react-calendar__tile.react-calendar__tile--hover:not(.react-calendar__tile--active) {
background-color: var(--cal-bg-subtle) !important;
}
.react-calendar__month-view__days > .react-calendar__tile.react-calendar__tile--hasActive,
.react-calendar__month-view__days > .react-calendar__tile.react-calendar__tile--active,
.react-calendar__month-view__days > button.react-calendar__tile.react-calendar__tile--active:hover {
background-color: var(--cal-bg-inverted);
color: var(--cal-text-inverted);
}
.react-calendar__tile.react-calendar__tile--active.react-calendar__month-view__days__day--weekend {
color: var(--cal-text-inverted);
}
.react-calendar__tile.react-calendar__month-view__days__day--weekend {
color: var(--cal-text-emphasis);
}
.react-calendar__tile.react-calendar__month-view__days__day--neighboringMonth {
color: var(--cal-text-muted);
}
.react-calendar__tile--now::before {
content: "\A";
width: 4px;
height: 4px;
border-radius: 50%;
position: absolute;
background-color: var(--cal-text-emphasis);
left: 50%;
transform: translateX(-50%);
bottom: 0;
z-index: inherit;
}
button.react-calendar__tile.react-calendar__month-view__days__day:hover,
.react-calendar__tile.react-calendar__year-view__months__month:hover {
background-color: var(--cal-bg-subtle);
color: var(--cal-text-emphasis);
}
.react-daterange-picker > .react-daterange-picker__wrapper:hover {
border-color: var(--cal-bg-inverted);
}
.react-daterange-picker.react-daterange-picker--disabled > .react-daterange-picker__wrapper:hover {
border-color: var(--cal-bg-subtle);
}
.react-calendar__navigation button.react-calendar__navigation__label:enabled:hover,
.react-calendar__navigation button.react-calendar__navigation__label:enabled:focus {
background-color: transparent;
}

View File

@@ -0,0 +1,34 @@
import "react-calendar/dist/Calendar.css";
import "react-date-picker/dist/DatePicker.css";
import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle";
import classNames from "@calcom/lib/classNames";
import { Icon } from "../../..";
type Props = {
date: Date;
onDatesChange?: ((date: Date) => void) | undefined;
className?: string;
disabled?: boolean;
minDate?: Date;
};
const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props) => {
return (
<PrimitiveDatePicker
className={classNames(
"focus:ring-primary-500 focus:border-primary-500 border-default rounded-md border p-1 pl-2 shadow-sm sm:text-sm",
className
)}
calendarClassName="rounded-md"
clearIcon={null}
calendarIcon={<Icon name="calendar" className="text-subtle h-5 w-5 rounded-md" />}
value={date}
disabled={disabled}
onChange={onDatesChange}
/>
);
};
export default DatePicker;

View File

@@ -0,0 +1,44 @@
/* eslint-disable playwright/missing-playwright-await */
import { render, fireEvent } from "@testing-library/react";
import { format } from "date-fns";
import { vi } from "vitest";
import DatePicker from "./DatePicker";
describe("Tests for DatePicker component", () => {
const date = new Date("2023-07-15");
test("Should display the selected date correctly and call the onDatesChange callback when the selected date changes", () => {
const mockOnDatesChange = vi.fn((changedDate: Date) => format(new Date(changedDate), "yyyy-MM-dd"));
const { container } = render(<DatePicker date={date} onDatesChange={mockOnDatesChange} />);
const day = container.querySelector('input[name="day"]') as HTMLInputElement;
const dayEvent = { target: { value: "27" } };
const month = container.querySelector('input[name="month"]') as HTMLInputElement;
const monthEvent = { target: { value: "06" } };
const year = container.querySelector('input[name="year"]') as HTMLInputElement;
const yearEvent = { target: { value: "2022" } };
fireEvent.change(day, dayEvent);
expect(mockOnDatesChange).toHaveReturnedWith("2023-07-27");
fireEvent.change(month, monthEvent);
expect(mockOnDatesChange).toHaveReturnedWith("2023-06-27");
fireEvent.change(year, yearEvent);
expect(mockOnDatesChange).toHaveReturnedWith("2022-06-27");
expect(mockOnDatesChange).toHaveBeenCalledTimes(3);
});
test("Should disable the DatePicker when disabled prop is true", () => {
const { getByDisplayValue } = render(<DatePicker date={date} disabled />);
const dateInput = getByDisplayValue(format(date, "yyyy-MM-dd")) as HTMLInputElement;
expect(dateInput).toBeDisabled();
});
});
HTMLCanvasElement.prototype.getContext = vi.fn() as never;

View File

@@ -0,0 +1 @@
export { default as DatePicker } from "./DatePicker";

View File

@@ -0,0 +1,192 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import Link from "next/link";
import type { ComponentProps } from "react";
import { forwardRef } from "react";
import { classNames } from "@calcom/lib";
import { Icon, type IconName } from "@calcom/ui";
import type { ButtonColor } from "../../button/Button";
export const Dropdown = DropdownMenuPrimitive.Root;
type DropdownMenuTriggerProps = ComponentProps<(typeof DropdownMenuPrimitive)["Trigger"]>;
export const DropdownMenuTrigger = forwardRef<HTMLButtonElement, DropdownMenuTriggerProps>(
({ className = "", ...props }, forwardedRef) => (
<DropdownMenuPrimitive.Trigger
{...props}
className={classNames(
!props.asChild &&
`focus:bg-subtle hover:bg-muted text-default group-hover:text-emphasis inline-flex items-center rounded-md bg-transparent px-3 py-2 text-sm font-medium ring-0 ${className}`
)}
ref={forwardedRef}
/>
)
);
DropdownMenuTrigger.displayName = "DropdownMenuTrigger";
export const DropdownMenuTriggerItem = DropdownMenuPrimitive.Trigger;
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
type DropdownMenuContentProps = ComponentProps<(typeof DropdownMenuPrimitive)["Content"]>;
export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
({ children, sideOffset = 2, align = "end", ...props }, forwardedRef) => {
return (
<DropdownMenuPrimitive.Content
align={align}
{...props}
sideOffset={sideOffset}
className={classNames(
"shadow-dropdown w-50 bg-default border-subtle relative z-10 ml-1.5 origin-top-right rounded-md border text-sm",
"[&>*:first-child]:mt-1 [&>*:last-child]:mb-1",
props.className
)}
ref={forwardedRef}>
{children}
</DropdownMenuPrimitive.Content>
);
}
);
DropdownMenuContent.displayName = "DropdownMenuContent";
type DropdownMenuLabelProps = ComponentProps<(typeof DropdownMenuPrimitive)["Label"]>;
export const DropdownMenuLabel = (props: DropdownMenuLabelProps) => (
<DropdownMenuPrimitive.Label {...props} className={classNames("text-subtle px-3 py-2", props.className)} />
);
type DropdownMenuItemProps = ComponentProps<(typeof DropdownMenuPrimitive)["CheckboxItem"]>;
export const DropdownMenuItem = forwardRef<HTMLDivElement, DropdownMenuItemProps>(
({ className = "", ...props }, forwardedRef) => (
<DropdownMenuPrimitive.Item
className={`focus:ring-brand-800 hover:bg-subtle hover:text-emphasis text-default text-sm ring-inset first-of-type:rounded-t-[inherit] last-of-type:rounded-b-[inherit] focus:outline-none focus:ring-1 ${className}`}
{...props}
ref={forwardedRef}
/>
)
);
DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
type DropdownMenuCheckboxItemProps = ComponentProps<(typeof DropdownMenuPrimitive)["CheckboxItem"]>;
export const DropdownMenuCheckboxItem = forwardRef<HTMLDivElement, DropdownMenuCheckboxItemProps>(
({ children, checked, onCheckedChange, ...props }, forwardedRef) => {
return (
<DropdownMenuPrimitive.CheckboxItem
{...props}
checked={checked}
onCheckedChange={onCheckedChange}
ref={forwardedRef}
className="hover:text-emphasis text-default hover:bg-subtle flex flex-1 items-center space-x-2 px-3 py-2 hover:outline-none hover:ring-0 disabled:cursor-not-allowed">
<div className="w-full">{children}</div>
{!checked && (
<input
aria-disabled={true}
aria-label={typeof children === "string" ? `Not active ${children}` : undefined}
aria-readonly
checked={false}
type="checkbox"
className="text-emphasis dark:text-muted focus:ring-emphasis border-default bg-default ml-auto h-4 w-4 rounded hover:cursor-pointer"
/>
)}
<DropdownMenuPrimitive.ItemIndicator asChild>
<input
aria-disabled={true}
aria-readonly
aria-label={typeof children === "string" ? `Active ${children}` : undefined}
checked={true}
type="checkbox"
className="text-emphasis dark:text-muted focus:ring-emphasis border-default bg-default h-4 w-4 rounded hover:cursor-pointer"
/>
</DropdownMenuPrimitive.ItemIndicator>
</DropdownMenuPrimitive.CheckboxItem>
);
}
);
DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem";
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
type DropdownMenuRadioItemProps = ComponentProps<(typeof DropdownMenuPrimitive)["RadioItem"]>;
export const DropdownMenuRadioItem = forwardRef<HTMLDivElement, DropdownMenuRadioItemProps>(
({ children, ...props }, forwardedRef) => {
return (
<DropdownMenuPrimitive.RadioItem {...props} ref={forwardedRef}>
{children}
<DropdownMenuPrimitive.ItemIndicator>
<Icon name="circle-check" />
</DropdownMenuPrimitive.ItemIndicator>
</DropdownMenuPrimitive.RadioItem>
);
}
);
DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem";
type DropdownItemProps = {
children: React.ReactNode;
color?: ButtonColor;
StartIcon?: IconName;
CustomStartIcon?: React.ReactNode;
EndIcon?: IconName;
href?: string;
disabled?: boolean;
childrenClassName?: string;
} & ButtonOrLinkProps;
type ButtonOrLinkProps = ComponentProps<"button"> & ComponentProps<"a">;
export function ButtonOrLink({ href, ...props }: ButtonOrLinkProps) {
const isLink = typeof href !== "undefined";
const ButtonOrLink = isLink ? "a" : "button";
const content = <ButtonOrLink {...props} />;
if (isLink) {
return (
<Link href={href} legacyBehavior>
{content}
</Link>
);
}
return content;
}
export const DropdownItem = (props: DropdownItemProps) => {
const { CustomStartIcon, StartIcon, EndIcon, children, color, childrenClassName, ...rest } = props;
return (
<ButtonOrLink
{...rest}
className={classNames(
"hover:text-emphasis text-default inline-flex w-full items-center space-x-2 px-3 py-2 disabled:cursor-not-allowed",
color === "destructive"
? "hover:bg-error hover:text-red-700 dark:hover:text-red-100"
: "hover:bg-subtle",
props.className
)}>
<>
{CustomStartIcon || (StartIcon && <Icon name={StartIcon} className="h-4 w-4" />)}
<div className={classNames("text-sm font-medium leading-5", childrenClassName)}>{children}</div>
{EndIcon && <Icon name={EndIcon} className="h-4 w-4" />}
</>
</ButtonOrLink>
);
};
type DropdownMenuSeparatorProps = ComponentProps<(typeof DropdownMenuPrimitive)["Separator"]>;
export const DropdownMenuSeparator = forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(
({ className = "", ...props }, forwardedRef) => {
return (
<DropdownMenuPrimitive.Separator
className={classNames("bg-emphasis my-1 h-px", className)}
{...props}
ref={forwardedRef}
/>
);
}
);
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
export default Dropdown;

View File

@@ -0,0 +1,361 @@
import { Canvas, Story, Meta } from "@storybook/addon-docs";
import {
Examples,
Example,
Title,
CustomArgsTable,
VariantRow,
VariantsTable,
} from "@calcom/storybook/components";
import { Button } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import {
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "./Dropdown";
<Meta title="UI/Form/Dropdown" component={Dropdown} />
<Title title="Dropdown" suffix="Brief" subtitle="Version 1.0 — Last Update: 29 Aug 2023" />
## Definition
`Dropdown` is an element that displays a menu to the user—such as a set of actions or functions.
## Structure
The `Dropdown` component can be used to display a menu to the user.
<CustomArgsTable of={Dropdown} />
### Dropdown components that have arguments:
#### DropdownMenuTrigger
<CustomArgsTable of={DropdownMenuTrigger} />
#### DropdownMenuContent
<CustomArgsTable of={DropdownMenuContent} />
#### DropdownMenuItem
<CustomArgsTable of={DropdownMenuItem} />
#### DropdownMenuSeparator
<CustomArgsTable of={DropdownMenuSeparator} />
## Examples
<Examples title="Dropdown">
<Example title="Simple">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="No modal">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
</Examples>
<Examples title="Dropdown Menu Trigger">
<Example title="Simple Button">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Button Icon">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button StartIcon="plus" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Disabled">
<Dropdown>
<DropdownMenuTrigger disabled={true} asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Not as child">
<Dropdown>
<DropdownMenuTrigger>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
</Examples>
<Examples title="Dropdown Menu Content">
<Example title="Custom width">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent style={{ minWidth: "200px" }}>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Align start">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent style={{ minWidth: "80px" }} align={"start"}>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Align center">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent style={{ minWidth: "80px" }} align={"center"}>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="With Menu Separator">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="With side offset">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={50}>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="With Some icons">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem StartIcon="trash">First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem StartIcon="plus">Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem StartIcon="copy">Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
</Examples>
<Examples title="Dropdown Menu Label">
<Example title="Simple">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"start"}>
<DropdownMenuLabel>Number</DropdownMenuLabel>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
</Examples>
## Dropdown Story
<Canvas>
<Story
name="Dropdown"
args={{
asChild: false,
align: "start",
disabled: false,
sideOffset: 0,
}}
argTypes={{
sideOffset: {
control: {
type: "number",
},
},
asChild: {
control: {
type: "boolean",
},
},
align: {
control: {
type: "inline-radio",
options: ["center", "start", "end"],
},
},
disabled: {
control: {
type: "boolean",
},
},
}}>
{({ dir, asChild, sideOffset, align, disabled }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<Dropdown>
<DropdownMenuTrigger disabled={disabled} asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} sideOffset={sideOffset}>
<DropdownMenuItem>
<DropdownItem StartIcon="trash">First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem StartIcon="plus">Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem StartIcon="copy">Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,16 @@
export {
Dropdown,
ButtonOrLink,
DropdownItem,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuTriggerItem,
} from "./Dropdown";

View File

@@ -0,0 +1,53 @@
export { Checkbox, MultiSelectCheckbox, CheckboxField } from "./checkbox";
export type { Option } from "./checkbox";
export { HintsOrErrors } from "./inputs/HintOrErrors";
export {
EmailField,
EmailInput,
FieldsetLegend,
InputGroupBox,
InputLeading,
PasswordField,
TextArea,
TextAreaField,
NumberInput,
FilterSearchField,
} from "./inputs/Input";
export { InputFieldWithSelect } from "./inputs/InputFieldWithSelect";
export { InputField, Input, TextField } from "./inputs/TextField";
export { InputError } from "./inputs/InputError";
export { Form } from "./inputs/Form";
export { Label } from "./inputs/Label";
export { Select, SelectField, SelectWithValidation, getReactSelectProps } from "./select";
export { TimezoneSelect, TimezoneSelectComponent } from "./timezone-select";
export type {
ITimezone,
ITimezoneOption,
TimezoneSelectComponentProps,
TimezoneSelectProps,
} from "./timezone-select";
export { DateRangePickerLazy as DateRangePicker } from "./date-range-picker";
export { BooleanToggleGroup, BooleanToggleGroupField, ToggleGroup } from "./toggleGroup";
export { DatePicker } from "./datepicker";
export { FormStep, Steps, Stepper } from "./step";
export { WizardForm } from "./wizard";
export { default as ColorPicker } from "./color-picker/colorpicker";
export {
Dropdown,
ButtonOrLink,
DropdownItem,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuTriggerItem,
} from "./dropdown";
export { SettingsToggle, Switch } from "./switch";

View File

@@ -0,0 +1,42 @@
import type { ReactElement, Ref } from "react";
import React, { forwardRef } from "react";
import type { FieldValues, SubmitHandler, UseFormReturn } from "react-hook-form";
import { FormProvider } from "react-hook-form";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { showToast } from "../../..";
type FormProps<T extends object> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
JSX.IntrinsicElements["form"],
"onSubmit"
>;
const PlainForm = <T extends FieldValues>(props: FormProps<T>, ref: Ref<HTMLFormElement>) => {
const { form, handleSubmit, ...passThrough } = props;
return (
<FormProvider {...form}>
<form
ref={ref}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
form
.handleSubmit(handleSubmit)(event)
.catch((err) => {
// FIXME: Booking Pages don't have toast, so this error is never shown
showToast(`${getErrorFromUnknown(err).message}`, "error");
});
}}
{...passThrough}>
{props.children}
</form>
</FormProvider>
);
};
export const Form = forwardRef(PlainForm) as <T extends FieldValues>(
p: FormProps<T> & { ref?: Ref<HTMLFormElement> }
) => ReactElement;

View File

@@ -0,0 +1,122 @@
import type { FieldValues } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { Icon } from "../../..";
import { InputError } from "./InputError";
type hintsOrErrorsProps = {
hintErrors?: string[];
fieldName: string;
t: (key: string) => string;
};
export function HintsOrErrors<T extends FieldValues = FieldValues>({
hintErrors,
fieldName,
t,
}: hintsOrErrorsProps) {
const methods = useFormContext() as ReturnType<typeof useFormContext> | null;
/* If there's no methods it means we're using these components outside a React Hook Form context */
if (!methods) return null;
const { formState } = methods;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const fieldErrors: FieldErrors<T> | undefined = formState.errors[fieldName];
if (!hintErrors && fieldErrors && !fieldErrors.message) {
// no hints passed, field errors exist and they are custom ones
return (
<div className="text-gray text-default mt-2 flex items-center text-sm">
<ul className="ml-2">
{Object.keys(fieldErrors).map((key: string) => {
return (
<li key={key} className="text-blue-700">
{t(`${fieldName}_hint_${key}`)}
</li>
);
})}
</ul>
</div>
);
}
if (hintErrors && fieldErrors) {
// hints passed, field errors exist
return (
<div className="text-gray text-default mt-2 flex items-center text-sm">
<ul className="ml-2">
{hintErrors.map((key: string) => {
const submitted = formState.isSubmitted;
const error = fieldErrors[key] || fieldErrors.message;
return (
<li
key={key}
data-testid="hint-error"
className={error !== undefined ? (submitted ? "text-red-500" : "") : "text-green-600"}>
{error !== undefined ? (
submitted ? (
<Icon
name="x"
size="12"
strokeWidth="3"
className="-ml-1 inline-block ltr:mr-2 rtl:ml-2"
/>
) : (
<Icon
name="circle"
fill="currentColor"
size="5"
className="inline-block ltr:mr-2 rtl:ml-2"
/>
)
) : (
<Icon
name="check"
size="12"
strokeWidth="3"
className="-ml-1 inline-block ltr:mr-2 rtl:ml-2"
/>
)}
{t(`${fieldName}_hint_${key}`)}
</li>
);
})}
</ul>
</div>
);
}
// errors exist, not custom ones, just show them as is
if (fieldErrors) {
return <InputError message={fieldErrors.message} />;
}
if (!hintErrors) return null;
// hints passed, no errors exist, proceed to just show hints
return (
<div className="text-gray text-default mt-2 flex items-center text-sm">
<ul className="ml-2">
{hintErrors.map((key: string) => {
// if field was changed, as no error exist, show checked status and color
const dirty = formState.dirtyFields[fieldName];
return (
<li key={key} className={!!dirty ? "text-green-600" : ""}>
{!!dirty ? (
<Icon
name="check"
size="12"
strokeWidth="3"
className="-ml-1 inline-block ltr:mr-2 rtl:ml-2"
/>
) : (
<Icon name="circle" fill="currentColor" size="5" className="inline-block ltr:mr-2 rtl:ml-2" />
)}
{t(`${fieldName}_hint_${key}`)}
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,200 @@
import type { ReactNode } from "react";
import React, { forwardRef, useCallback, useId, useState } from "react";
import { useFormContext } from "react-hook-form";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Icon, Input, InputField, Tooltip } from "../../..";
import { Label } from "./Label";
import type { InputFieldProps } from "./types";
export function InputLeading(props: JSX.IntrinsicElements["div"]) {
return (
<span className="bg-muted border-default text-subtle inline-flex flex-shrink-0 items-center rounded-l-sm border px-3 ltr:border-r-0 rtl:border-l-0 sm:text-sm sm:leading-4">
{props.children}
</span>
);
}
export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(function PasswordField(
props,
ref
) {
const { t } = useLocale();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const toggleIsPasswordVisible = useCallback(
() => setIsPasswordVisible(!isPasswordVisible),
[isPasswordVisible, setIsPasswordVisible]
);
const textLabel = isPasswordVisible ? t("hide_password") : t("show_password");
return (
<InputField
type={isPasswordVisible ? "text" : "password"}
placeholder={props.placeholder || "•••••••••••••"}
ref={ref}
{...props}
className={classNames(
"addon-wrapper mb-0 ltr:border-r-0 ltr:pr-10 rtl:border-l-0 rtl:pl-10",
props.className
)}
addOnFilled={false}
addOnSuffix={
<Tooltip content={textLabel}>
<button
className="text-emphasis h-9"
tabIndex={-1}
type="button"
onClick={() => toggleIsPasswordVisible()}>
{isPasswordVisible ? (
<Icon name="eye-off" className="h-4 stroke-[2.5px]" />
) : (
<Icon name="eye" className="h-4 stroke-[2.5px]" />
)}
<span className="sr-only">{textLabel}</span>
</button>
</Tooltip>
}
/>
);
});
export const EmailInput = forwardRef<HTMLInputElement, InputFieldProps>(function EmailInput(props, ref) {
return (
<Input
ref={ref}
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
inputMode="email"
{...props}
/>
);
});
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
return (
<InputField
ref={ref}
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
inputMode="email"
{...props}
/>
);
});
type TextAreaProps = JSX.IntrinsicElements["textarea"];
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextAreaInput(props, ref) {
return (
<textarea
ref={ref}
{...props}
className={classNames(
"hover:border-emphasis border-default bg-default placeholder:text-muted text-emphasis disabled:hover:border-default disabled:bg-subtle focus:ring-brand-default focus:border-subtle mb-2 block w-full rounded-md border px-3 py-2 text-sm transition focus:outline-none focus:ring-2 focus:ring-offset-1 disabled:cursor-not-allowed",
props.className
)}
/>
);
});
type TextAreaFieldProps = {
label?: ReactNode;
t?: (key: string) => string;
} & React.ComponentProps<typeof TextArea> & {
name: string;
labelProps?: React.ComponentProps<typeof Label>;
};
export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(function TextField(
props,
ref
) {
const id = useId();
const { t: _t } = useLocale();
const t = props.t || _t;
const methods = useFormContext();
const {
label = t(props.name as string),
labelProps,
/** Prevents displaying untranslated placeholder keys */
placeholder = t(`${props.name}_placeholder`) !== `${props.name}_placeholder`
? `${props.name}_placeholder`
: "",
...passThrough
} = props;
return (
<div>
{!!props.name && (
<Label htmlFor={id} {...labelProps}>
{label}
</Label>
)}
<TextArea ref={ref} placeholder={placeholder} {...passThrough} />
{methods?.formState?.errors[props.name]?.message && (
<Alert
className="mt-1"
severity="error"
message={<>{methods.formState.errors[props.name]?.message}</>}
/>
)}
</div>
);
});
export function FieldsetLegend(props: JSX.IntrinsicElements["legend"]) {
return (
<legend {...props} className={classNames("text-default text-sm font-medium leading-4", props.className)}>
{props.children}
</legend>
);
}
export function InputGroupBox(props: JSX.IntrinsicElements["div"]) {
return (
<div
{...props}
className={classNames("bg-default border-default space-y-2 rounded-sm border p-2", props.className)}>
{props.children}
</div>
);
}
export const NumberInput = forwardRef<HTMLInputElement, InputFieldProps>(function NumberInput(props, ref) {
return (
<Input
ref={ref}
type="number"
autoCapitalize="none"
autoComplete="numeric"
autoCorrect="off"
inputMode="numeric"
{...props}
/>
);
});
export const FilterSearchField = forwardRef<HTMLInputElement, InputFieldProps>(function TextField(
props,
ref
) {
return (
<div
dir="ltr"
className="focus-within:ring-brand-default group relative mx-3 mb-1 mt-2.5 flex items-center rounded-md focus-within:outline-none focus-within:ring-2">
<div className="addon-wrapper border-default [input:hover_+_&]:border-emphasis [input:hover_+_&]:border-l-default [&:has(+_input:hover)]:border-emphasis [&:has(+_input:hover)]:border-r-default flex h-7 items-center justify-center rounded-l-md border border-r-0">
<Icon name="search" className="ms-3 h-4 w-4" data-testid="search-icon" />
</div>
<Input
ref={ref}
className="disabled:bg-subtle disabled:hover:border-subtle !my-0 h-7 rounded-l-none border-l-0 !ring-0 disabled:cursor-not-allowed"
{...props}
/>
</div>
);
});

View File

@@ -0,0 +1,14 @@
import { Icon } from "../../..";
type InputErrorProp = {
message: string;
};
export const InputError = ({ message }: InputErrorProp) => (
<div data-testid="field-error" className="text-gray mt-2 flex items-center gap-x-2 text-sm text-red-700">
<div>
<Icon name="info" className="h-3 w-3" />
</div>
<p>{message}</p>
</div>
);

View File

@@ -0,0 +1,19 @@
import React, { forwardRef } from "react";
import { InputField, UnstyledSelect } from "../../..";
import type { InputFieldProps } from "./types";
export const InputFieldWithSelect = forwardRef<
HTMLInputElement,
InputFieldProps & { selectProps: typeof UnstyledSelect }
>(function EmailField(props, ref) {
return (
<InputField
ref={ref}
{...props}
inputIsFullWidth={false}
addOnClassname="!px-0"
addOnSuffix={<UnstyledSelect {...props.selectProps} />}
/>
);
});

View File

@@ -0,0 +1,15 @@
import { classNames } from "@calcom/lib";
export function Label(props: JSX.IntrinsicElements["label"]) {
const { className, ...restProps } = props;
return (
<label
className={classNames(
"text-default text-emphasis mb-2 block text-sm font-medium leading-none",
className
)}
{...restProps}>
{props.children}
</label>
);
}

View File

@@ -0,0 +1,193 @@
import React, { forwardRef, useId, useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, Skeleton } from "../../..";
import { HintsOrErrors } from "./HintOrErrors";
import { Label } from "./Label";
import type { InputFieldProps, InputProps } from "./types";
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ isFullWidth = true, ...props },
ref
) {
return (
<input
{...props}
ref={ref}
className={classNames(
"hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis disabled:hover:border-default disabled:bg-subtle focus:ring-brand-default focus:border-subtle mb-2 block h-9 rounded-md border px-3 py-2 text-sm leading-4 transition focus:outline-none focus:ring-2 disabled:cursor-not-allowed",
isFullWidth && "w-full",
props.className
)}
/>
);
});
type AddonProps = {
children: React.ReactNode;
isFilled?: boolean;
className?: string;
error?: boolean;
onClickAddon?: () => void;
};
const Addon = ({ isFilled, children, className, error, onClickAddon }: AddonProps) => (
<div
onClick={onClickAddon && onClickAddon}
className={classNames(
"addon-wrapper border-default [input:hover_+_&]:border-emphasis [input:hover_+_&]:border-l-default [&:has(+_input:hover)]:border-emphasis [&:has(+_input:hover)]:border-r-default h-9 border px-3",
isFilled && "bg-subtle",
onClickAddon && "cursor-pointer disabled:hover:cursor-not-allowed",
className
)}>
<div
className={classNames(
"min-h-9 flex flex-col justify-center text-sm leading-7",
error ? "text-error" : "text-default"
)}>
<span
className="flex max-w-2xl overflow-y-auto whitespace-nowrap"
style={{
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none",
overflow: "-ms-scroll-chaining",
msOverflowStyle: "-ms-autohiding-scrollbar",
}}>
{children}
</span>
</div>
</div>
);
export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(props, ref) {
const id = useId();
const { t: _t, isLocaleReady, i18n } = useLocale();
const t = props.t || _t;
const name = props.name || "";
const {
label = t(name),
labelProps,
labelClassName,
disabled,
LockedIcon,
placeholder = isLocaleReady && i18n.exists(`${name}_placeholder`) ? t(`${name}_placeholder`) : "",
className,
addOnLeading,
addOnSuffix,
addOnFilled = true,
addOnClassname,
inputIsFullWidth,
hint,
type,
hintErrors,
labelSrOnly,
containerClassName,
readOnly,
showAsteriskIndicator,
onClickAddon,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
t: __t,
dataTestid,
...passThrough
} = props;
const [inputValue, setInputValue] = useState<string>("");
return (
<div className={classNames(containerClassName)}>
{!!name && (
<Skeleton
as={Label}
htmlFor={id}
loadingClassName="w-16"
{...labelProps}
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
{label}
{showAsteriskIndicator && !readOnly && passThrough.required ? (
<span className="text-default ml-1 font-medium">*</span>
) : null}
{LockedIcon}
</Skeleton>
)}
{addOnLeading || addOnSuffix ? (
<div
dir="ltr"
className="focus-within:ring-brand-default group relative mb-1 flex items-center rounded-md transition focus-within:outline-none focus-within:ring-2">
{addOnLeading && (
<Addon
isFilled={addOnFilled}
className={classNames("ltr:rounded-l-md rtl:rounded-r-md", addOnClassname)}>
{addOnLeading}
</Addon>
)}
<Input
data-testid={`${dataTestid}-input` ?? "input-field"}
id={id}
type={type}
placeholder={placeholder}
isFullWidth={inputIsFullWidth}
className={classNames(
className,
"disabled:bg-subtle disabled:hover:border-subtle disabled:cursor-not-allowed",
addOnLeading && "rounded-l-none border-l-0",
addOnSuffix && "rounded-r-none border-r-0",
type === "search" && "pr-8",
"!my-0 !ring-0"
)}
{...passThrough}
{...(type == "search" && {
onChange: (e) => {
setInputValue(e.target.value);
props.onChange && props.onChange(e);
},
value: inputValue,
})}
disabled={readOnly || disabled}
ref={ref}
/>
{addOnSuffix && (
<Addon
onClickAddon={onClickAddon}
isFilled={addOnFilled}
className={classNames("ltr:rounded-r-md rtl:rounded-l-md", addOnClassname)}>
{addOnSuffix}
</Addon>
)}
{type === "search" && inputValue?.toString().length > 0 && (
<Icon
name="x"
className="text-subtle absolute top-2.5 h-4 w-4 cursor-pointer ltr:right-2 rtl:left-2"
onClick={(e) => {
setInputValue("");
props.onChange && props.onChange(e as unknown as React.ChangeEvent<HTMLInputElement>);
}}
/>
)}
</div>
) : (
<Input
id={id}
type={type}
placeholder={placeholder}
className={classNames(
className,
"disabled:bg-subtle disabled:hover:border-subtle disabled:cursor-not-allowed"
)}
{...passThrough}
readOnly={readOnly}
ref={ref}
isFullWidth={inputIsFullWidth}
disabled={readOnly || disabled}
/>
)}
<HintsOrErrors hintErrors={hintErrors} fieldName={name} t={t} />
{hint && <div className="text-default mt-2 flex items-center text-sm">{hint}</div>}
</div>
);
});
export const TextField = forwardRef<HTMLInputElement, InputFieldProps>(function TextField(props, ref) {
return <InputField ref={ref} {...props} />;
});

View File

@@ -0,0 +1,182 @@
/* eslint-disable playwright/missing-playwright-await */
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { render, fireEvent } from "@testing-library/react";
import { vi } from "vitest";
import type { UnstyledSelect } from "../../../form/Select";
import { EmailField, TextAreaField, PasswordField, NumberInput, FilterSearchField } from "./Input";
import { InputFieldWithSelect } from "./InputFieldWithSelect";
import { InputField } from "./TextField";
const onChangeMock = vi.fn();
describe("Tests for InputField Component", () => {
test("Should render correctly with label and placeholder", () => {
const { getByLabelText, getByPlaceholderText } = render(
<InputField name="testInput" label="Test Label" placeholder="Test Placeholder" />
);
expect(getByLabelText("Test Label")).toBeInTheDocument();
expect(getByPlaceholderText("Test Placeholder")).toBeInTheDocument();
});
test("Should handle input correctly", () => {
const { getByRole } = render(<InputField name="testInput" onChange={onChangeMock} />);
const inputElement = getByRole("textbox") as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: "Hello" } });
expect(onChangeMock).toHaveBeenCalledTimes(1);
expect(inputElement.value).toBe("Hello");
});
it("should render with addOnLeading prop", () => {
const { getByText } = render(<InputField addOnLeading={<span>Leading</span>} />);
const addOnLeadingElement = getByText("Leading");
expect(addOnLeadingElement).toBeInTheDocument();
});
it("should render with addOnSuffix prop", () => {
const { getByText } = render(<InputField addOnSuffix={<span>Suffix</span>} />);
const addOnSuffixElement = getByText("Suffix");
expect(addOnSuffixElement).toBeInTheDocument();
});
it("should display both addOnLeading and addOnSuffix", () => {
const { getByText } = render(
<InputField addOnLeading={<span>Leading</span>} addOnSuffix={<span>Suffix</span>} />
);
const addOnLeadingElement = getByText("Leading");
const addOnSuffixElement = getByText("Suffix");
expect(addOnLeadingElement).toBeInTheDocument();
expect(addOnSuffixElement).toBeInTheDocument();
});
it("Should display error message when error prop is provided", () => {
const errorMessage = "This field is required";
const { getByRole } = render(<InputField error={errorMessage} />);
const errorElement = getByRole("textbox");
expect(errorElement).toHaveAttribute("error", errorMessage);
});
});
describe("Tests for PasswordField Component", () => {
test("Should toggle password visibility correctly", () => {
const { getByLabelText, getByText } = render(
<TooltipProvider>
<PasswordField name="password" />
</TooltipProvider>
);
const passwordInput = getByLabelText("password") as HTMLInputElement;
const toggleButton = getByText("show_password");
expect(passwordInput.type).toBe("password");
fireEvent.click(toggleButton);
expect(passwordInput.type).toBe("text");
fireEvent.click(toggleButton);
expect(passwordInput.type).toBe("password");
});
});
describe("Tests for EmailField Component", () => {
test("Should render correctly with email-related attributes", () => {
const { getByRole } = render(<EmailField name="email" />);
const emailInput = getByRole("textbox");
expect(emailInput).toHaveAttribute("type", "email");
expect(emailInput).toHaveAttribute("autoCapitalize", "none");
expect(emailInput).toHaveAttribute("autoComplete", "email");
expect(emailInput).toHaveAttribute("autoCorrect", "off");
expect(emailInput).toHaveAttribute("inputMode", "email");
});
});
describe("Tests for TextAreaField Component", () => {
test("Should render correctly with label and placeholder", () => {
const { getByText, getByPlaceholderText, getByRole } = render(
<TextAreaField name="testTextArea" label="Test Label" placeholder="Test Placeholder" />
);
expect(getByText("Test Label")).toBeInTheDocument();
expect(getByPlaceholderText("Test Placeholder")).toBeInTheDocument();
expect(getByRole("textbox")).toBeInTheDocument();
});
test("Should handle input correctly", () => {
const { getByRole } = render(<TextAreaField name="testTextArea" onChange={onChangeMock} />);
const textareaElement = getByRole("textbox") as HTMLInputElement;
fireEvent.change(textareaElement, { target: { value: "Hello" } });
expect(onChangeMock).toHaveBeenCalled();
expect(textareaElement.value).toBe("Hello");
});
});
describe("Tests for InputFieldWithSelect Component", () => {
test("Should render correctly with InputField and UnstyledSelect", () => {
const onChangeMock = vi.fn();
const selectProps = {
value: null,
onChange: onChangeMock,
name: "testSelect",
options: [
{ value: "Option 1", label: "Option 1" },
{ value: "Option 2", label: "Option 2" },
{ value: "Option 3", label: "Option 3" },
],
} as unknown as typeof UnstyledSelect;
const { getByText } = render(<InputFieldWithSelect selectProps={selectProps} label="testSelect" />);
const inputElement = getByText("Select...");
fireEvent.mouseDown(inputElement);
const optionElement = getByText("Option 1");
expect(optionElement).toBeInTheDocument();
});
});
describe("Tests for NumberInput Component", () => {
test("Should render correctly with input type number", () => {
const { getByRole } = render(<NumberInput name="numberInput" />);
const numberInput = getByRole("spinbutton");
expect(numberInput).toBeInTheDocument();
expect(numberInput).toHaveAttribute("type", "number");
});
test("Should handle input correctly", () => {
const { getByRole } = render(<NumberInput name="numberInput" onChange={onChangeMock} />);
const numberInput = getByRole("spinbutton") as HTMLInputElement;
fireEvent.change(numberInput, { target: { value: "42" } });
expect(onChangeMock).toHaveBeenCalled();
expect(numberInput.value).toBe("42");
});
});
describe("Tests for FilterSearchField Component", () => {
test("Should render correctly with Search icon and input", async () => {
const { getByRole, findByTestId } = render(<FilterSearchField name="searchField" />);
const searchInput = getByRole("textbox");
const searchIcon = await findByTestId("search-icon");
expect(searchInput).toBeInTheDocument();
expect(searchIcon).toBeInTheDocument();
});
test("Should handle input correctly", () => {
const { getByRole } = render(<FilterSearchField name="searchField" onChange={onChangeMock} />);
const searchInput = getByRole("textbox") as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: "Test search" } });
expect(onChangeMock).toHaveBeenCalled();
expect(searchInput.value).toBe("Test search");
});
});

View File

@@ -0,0 +1,100 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
Examples,
Example,
Title,
CustomArgsTable,
VariantRow,
VariantsTable,
} from "@calcom/storybook/components";
import { InputFieldWithSelect } from "./InputFieldWithSelect";
import { InputField } from "./TextField";
<Meta title="UI/Form/Input Field" component={InputField} />
<Title title="Inputs" suffix="Brief" subtitle="Version 2.0 — Last Update: 24 Aug 2023" />
## Definition
Text fields allow users to input and edit text into the product. Usually appear in forms and modals. Various options can be shown with the field to communicate the input requirements.## Structure
## Structure
<CustomArgsTable of={InputField} />
<Examples
title="Inputs"
footnote={
<ul>
<li>The width is flexible but the height is fixed for both desktop and mobile. </li>
</ul>
}>
<Example title="Default">
<InputField placeholder="Default" />
</Example>
</Examples>
<Examples title="Input Types">
<Example title="Default">
<InputField placeholder="Default" />
</Example>
<Example title="Prefix">
<InputField placeholder="Prefix" addOnLeading={<>Prefix</>} />
</Example>
<Example title="Suffix">
<InputField placeholder="Suffic" addOnSuffix={<>Suffix</>} />
</Example>
<Example title="Suffix With Select">
<InputFieldWithSelect
placeholder="Suffix"
selectProps={{ options: [{ value: "TEXT", label: "Text" }] }}
/>
</Example>
<Example title="Focused">
<InputField placeholder="Focused" className="sb-pseudo--focus" />
</Example>
<Example title="Hovered">
<InputField placeholder="Hovered" className="sb-pseudo--hover" />
</Example>
<Example title="Error">
<InputField placeholder="Error" error="Error" />
</Example>
</Examples>
<Examples title="Input Text">
<Example title="Default">
<InputField />
</Example>
<Example title="Placeholder">
<InputField placeholder="Placeholder" />
</Example>
<Example title="Filled">
<InputField value="Filled" />
</Example>
</Examples>
<Title offset title="Input" suffix="Variants" />
<Canvas>
<Story name="All Variants">
<VariantsTable titles={["Default", "Focused", "Hovered"]} columnMinWidth={150}>
<VariantRow variant="Default">
<InputField placeholder="Default" />
<InputField placeholder="Focused" className="sb-pseudo--focus" />
<InputField placeholder="Hovered" className="sb-pseudo--hover" />
</VariantRow>
<VariantRow variant="Prefixed">
<InputField placeholder="Default" addOnLeading={<>Prefix</>} />
<InputField placeholder="Focused" className="sb-pseudo--focus" addOnLeading={<>Prefix</>} />
<InputField placeholder="Hovered" className="sb-pseudo--hover" addOnLeading={<>Prefix</>} />
</VariantRow>
<VariantRow variant="Suffix">
<InputField placeholder="Default" addOnSuffix={<>Prefix</>} />
<InputField placeholder="Focused" className="sb-pseudo--focus" addOnSuffix={<>Prefix</>} />
<InputField placeholder="Hovered" className="sb-pseudo--hover" addOnSuffix={<>Prefix</>} />
</VariantRow>
</VariantsTable>
</Story>
</Canvas>

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import type { Input } from "./TextField";
export type InputFieldProps = {
label?: ReactNode;
LockedIcon?: React.ReactNode;
hint?: ReactNode;
hintErrors?: string[];
addOnLeading?: ReactNode;
addOnSuffix?: ReactNode;
inputIsFullWidth?: boolean;
addOnFilled?: boolean;
addOnClassname?: string;
error?: string;
labelSrOnly?: boolean;
containerClassName?: string;
showAsteriskIndicator?: boolean;
t?: (key: string) => string;
dataTestid?: string;
onClickAddon?: () => void;
} & React.ComponentProps<typeof Input> & {
labelProps?: React.ComponentProps<typeof Label>;
labelClassName?: string;
};
export type InputProps = JSX.IntrinsicElements["input"] & { isFullWidth?: boolean };

View File

@@ -0,0 +1,209 @@
import { useId } from "@radix-ui/react-id";
import * as React from "react";
import type { GroupBase, Props, SingleValue, MultiValue } from "react-select";
import ReactSelect from "react-select";
import cx from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label } from "../inputs/Label";
import { getReactSelectProps } from "./selectTheme";
export type SelectProps<
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
> = Props<Option, IsMulti, Group> & { variant?: "default" | "checkbox"; "data-testid"?: string };
export const Select = <
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
components,
variant = "default",
...props
}: SelectProps<Option, IsMulti, Group> & {
innerClassNames?: {
input?: string;
option?: string;
control?: string;
singleValue?: string;
valueContainer?: string;
multiValue?: string;
menu?: string;
menuList?: string;
};
}) => {
const { classNames, innerClassNames, menuPlacement = "auto", ...restProps } = props;
const reactSelectProps = React.useMemo(() => {
return getReactSelectProps<Option, IsMulti, Group>({
components: components || {},
menuPlacement,
});
}, [components, menuPlacement]);
// Annoyingly if we update styles here we have to update timezone select too
// We cant create a generate function for this as we can't force state changes - onSelect styles dont change for example
return (
<ReactSelect
{...reactSelectProps}
menuPlacement={menuPlacement}
classNames={{
input: () => cx("text-emphasis", innerClassNames?.input),
option: (state) =>
cx(
"bg-default flex cursor-pointer justify-between py-2.5 px-3 rounded-none text-default ",
state.isFocused && "bg-subtle",
state.isDisabled && "bg-muted",
state.isSelected && "bg-emphasis text-default",
innerClassNames?.option
),
placeholder: (state) => cx("text-muted", state.isFocused && variant !== "checkbox" && "hidden"),
dropdownIndicator: () => "text-default",
control: (state) =>
cx(
"bg-default border-default !min-h-9 h-9 text-sm leading-4 placeholder:text-sm placeholder:font-normal dark:focus:border-emphasis focus-within:outline-none focus-within:ring-2 focus-within:ring-brand-default hover:border-emphasis rounded-md border",
state.isMulti
? variant === "checkbox"
? "px-3 py-2 h-fit"
: state.hasValue
? "p-1 h-fit"
: "px-3 py-2 h-fit"
: "py-2 px-3",
props.isDisabled && "bg-subtle",
innerClassNames?.control
),
singleValue: () => cx("text-emphasis placeholder:text-muted", innerClassNames?.singleValue),
valueContainer: () =>
cx("text-emphasis placeholder:text-muted flex gap-1", innerClassNames?.valueContainer),
multiValue: () =>
cx(
"bg-subtle text-default rounded-md py-1.5 px-2 flex items-center text-sm leading-tight",
innerClassNames?.multiValue
),
menu: () =>
cx(
" rounded-md bg-default text-sm leading-4 text-default mt-1 border border-subtle",
innerClassNames?.menu
),
groupHeading: () => "leading-none text-xs uppercase text-default pl-2.5 pt-4 pb-2",
menuList: () => cx("scroll-bar scrollbar-track-w-20 rounded-md", innerClassNames?.menuList),
indicatorsContainer: (state) =>
cx(
state.selectProps.menuIsOpen
? state.isMulti
? "[&>*:last-child]:rotate-180 [&>*:last-child]:transition-transform"
: "rotate-180 transition-transform"
: "text-default" // Woo it adds another SVG here on multi for some reason
),
multiValueRemove: () => "text-default py-auto ml-2",
...classNames,
}}
{...restProps}
/>
);
};
export const SelectField = function SelectField<
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>(
props: {
required?: boolean;
name?: string;
containerClassName?: string;
label?: string;
labelProps?: React.ComponentProps<typeof Label>;
className?: string;
error?: string;
} & SelectProps<Option, IsMulti, Group>
) {
const { t } = useLocale();
const { label = t(props.name || ""), containerClassName, labelProps, className, ...passThrough } = props;
const id = useId();
return (
<div className={cx(containerClassName)}>
<div className={cx(className)}>
{!!label && (
<Label htmlFor={id} {...labelProps} className={cx(props.error && "text-error")}>
{label}
</Label>
)}
</div>
<Select {...passThrough} />
</div>
);
};
/**
* TODO: It should replace Select after through testing
*/
export function SelectWithValidation<
Option extends { label: string; value: string },
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
required = false,
onChange,
value,
...remainingProps
}: SelectProps<Option, IsMulti, Group> & { required?: boolean }) {
const [hiddenInputValue, _setHiddenInputValue] = React.useState(() => {
if (value instanceof Array || !value) {
return "";
}
return value.value || "";
});
const setHiddenInputValue = React.useCallback((value: MultiValue<Option> | SingleValue<Option>) => {
let hiddenInputValue = "";
if (value instanceof Array) {
hiddenInputValue = value.map((val) => val.value).join(",");
} else {
hiddenInputValue = value?.value || "";
}
_setHiddenInputValue(hiddenInputValue);
}, []);
React.useEffect(() => {
if (!value) {
return;
}
setHiddenInputValue(value);
}, [value, setHiddenInputValue]);
return (
<div className={cx("relative", remainingProps.className)}>
<Select
value={value}
{...remainingProps}
onChange={(value, ...remainingArgs) => {
setHiddenInputValue(value);
if (onChange) {
onChange(value, ...remainingArgs);
}
}}
/>
{required && (
<input
tabIndex={-1}
autoComplete="off"
style={{
opacity: 0,
width: "100%",
height: 1,
position: "absolute",
}}
value={hiddenInputValue}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange={() => {}}
// TODO:Not able to get focus to work
// onFocus={() => selectRef.current?.focus()}
required={required}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import type { GroupBase, InputProps, OptionProps, ControlProps } from "react-select";
import { components as reactSelectComponents } from "react-select";
import { classNames } from "@calcom/lib";
import { Icon } from "../../..";
import { UpgradeTeamsBadge } from "../../badge";
import type { SelectProps } from "./Select";
export const InputComponent = <
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
inputClassName,
...props
}: InputProps<Option, IsMulti, Group>) => {
return (
<reactSelectComponents.Input
// disables our default form focus hightlight on the react-select input element
inputClassName={classNames(
"focus:ring-0 focus:ring-offset-0 dark:!text-darkgray-900 !text-emphasis",
inputClassName
)}
{...props}
/>
);
};
type ExtendedOption = {
value: string | number;
label: string;
needsTeamsUpgrade?: boolean;
};
export const OptionComponent = <
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
...props
}: OptionProps<Option, IsMulti, Group>) => {
return (
// This gets styled in the select classNames prop now - handles overrides with styles vs className here doesnt
<reactSelectComponents.Option {...props}>
<div className="flex">
<span className="mr-auto" data-testid={`select-option-${(props as unknown as ExtendedOption).value}`}>
{props.label || <>&nbsp;</>}
</span>
{(props.data as unknown as ExtendedOption).needsTeamsUpgrade ? <UpgradeTeamsBadge /> : <></>}
{props.isSelected && <Icon name="check" className="ml-2 h-4 w-4" />}
</div>
</reactSelectComponents.Option>
);
};
export const ControlComponent = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option> = GroupBase<Option>
>(
controlProps: ControlProps<Option, IsMulti, Group> & {
selectProps: SelectProps<Option, IsMulti, Group>;
}
) => {
const dataTestId = controlProps.selectProps["data-testid"] ?? "select-control";
return (
<span data-testid={dataTestId}>
<reactSelectComponents.Control {...controlProps} />
</span>
);
};
// We need to override this component if we need a icon - we can't simpily override styles
type IconLeadingProps = {
icon: React.ReactNode;
children?: React.ReactNode;
} & React.ComponentProps<typeof reactSelectComponents.Control>;
export const IconLeading = ({ icon, children, ...props }: IconLeadingProps) => {
return (
<reactSelectComponents.Control {...props}>
{icon}
{children}
</reactSelectComponents.Control>
);
};

View File

@@ -0,0 +1,3 @@
export { SelectWithValidation, SelectField, Select } from "./Select";
export { getReactSelectProps } from "./selectTheme";

View File

@@ -0,0 +1,129 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
Examples,
Example,
Title,
CustomArgsTable,
VariantRow,
VariantsTable,
} from "@calcom/storybook/components";
import { SelectField } from "./Select";
<Meta title="UI/Form/Select Field" component={SelectField} />
<Title title="Select" suffix="Brief" subtitle="Version 2.0 — Last Update: 29 Aug 2022" />
## Definition
Dropdown fields allow users to input existing options that is preset by the deisgner/ developer. It can be just one choice per field, or they might be multiple choices depends on the circumstances.
## Structure
<CustomArgsTable of={SelectField} />
export const options = [
{ value: 0, label: "Option One" },
{ value: 1, label: "Option Two" },
{ value: 3, label: "Option Three" },
{ value: 4, label: "Option Four" },
];
## Examples
<Examples
title=" Single Selected / Unselected"
footnote={
<ul>
<li>The difference between the types are when they are filled. </li>
</ul>
}>
<Example title="Single Select [Unselected]" isFullWidth>
<SelectField label={"Single Select"} options={options} />
</Example>
<Example title="Single Select [Selected]" isFullWidth>
<SelectField label={"Single Select"} options={options} defaultValue={options[0]} />
</Example>
<Example title="Multi Select [Unselected]" isFullWidth>
<SelectField label={"Multi Select"} options={options} isMulti={true} />
</Example>
<Example title="Multi Select [Selected]" isFullWidth>
<SelectField label={"Multi Select"} options={options} isMulti={true} defaultValue={options[0]} />
</Example>
</Examples>
<Examples title="Variants">
<Example title="Default">
<SelectField label={"Default Select"} options={options} />
</Example>
<Example title="Icon Left">
WIP
{/* <SelectField options={options} components={{ Control }}/> */}
</Example>
</Examples>
## Variant Caviats (WIP) - To be updated
Using Icons is a bit of a strange one cause you can't simpily pass in an icon as a prop. You have to pass in a component. To the select field.
```js
// Bad: Inline declaration will cause remounting issues
const BadSelect = (props) => (
<Select
{...props}
components={{
Control: ({ children, ...rest }) => <components.Control {...rest}>👎 {children}</components.Control>,
}}
/>
);
// Good: Custom component declared outside of the Select scope
const Control = <IconLeading icon="plus" />;
const GoodSelect = (props) => <Select {...props} components={{ Control }} />;
```
<Examples title="States ">
<Example title="Default">
<SelectField options={options} label={"Default Select"} />
</Example>
{/* <Example title="Hover">
<SelectField options={options} className="sb-pseudo--hover"/>
</Example>
<Example title="Focus">
<SelectField options={options} className="sb-pseudo--focus"/>
</Example> */}
</Examples>
## Select Story
<Canvas>
<Story
name="Default"
args={{
required: false,
name: "select-field",
error: "Some error",
variant: "default",
label: "Select an item",
isMulti: false,
options,
}}
argTypes={{
variant: {
control: {
type: "select",
options: ["default", "checkbox"],
},
},
}}>
{(args) => (
<VariantsTable titles={["Default"]} columnMinWidth={300}>
<VariantRow>
<SelectField {...args} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,308 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable playwright/missing-playwright-await */
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { Select, SelectField, SelectWithValidation } from "./Select";
const options = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
];
const onChangeMock = vi.fn();
const props: any = {
name: "test",
options: options,
defaultValue: { label: "Option 3", value: "option3" },
onChange: onChangeMock,
};
const classNames = {
singleValue: () => "w-1",
valueContainer: () => "w-2",
control: () => "w-3",
input: () => "w-4",
option: () => "w-5",
menuList: () => "w-6",
menu: () => "w-7",
multiValue: () => "w-8",
};
const renderSelectWithForm = (newProps?: any) => {
render(
<form aria-label="test-form">
<label htmlFor="test">Test</label>
<Select {...props} {...newProps} inputId="test" />
<p>Click Outside</p>
</form>
);
};
const selectOption = async (optionText: string) => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText(optionText);
const option = screen.getByText(optionText);
fireEvent.click(option);
};
describe("Tests for Select File", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Test Select Component", () => {
describe("Tests the default Behavior of Select component", () => {
beforeEach(() => {
renderSelectWithForm();
});
test("Should render with the correct default value", async () => {
expect(screen.getByText("Option 3")).toBeInTheDocument();
expect(screen.getByRole("form")).toHaveFormValues({ test: "option3" });
});
test("Should select a correct option", async () => {
await waitFor(async () => {
await selectOption("Option 2");
});
expect(onChangeMock).toHaveBeenCalledWith(
expect.objectContaining({
label: "Option 2",
value: "option2",
}),
expect.objectContaining({
action: "select-option",
name: "test",
option: undefined,
})
);
expect(screen.getByRole("form")).toHaveFormValues({ test: "option2" });
});
test("Should keep the default value after selections are shown and the user clicks outside the selection element", async () => {
await waitFor(async () => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText("Option 2");
const outsideButton = screen.getByText("Click Outside");
fireEvent.click(outsideButton);
});
expect(screen.getByRole("form")).toHaveFormValues({ test: "option3" });
});
test("Should keep the selected value after the user has selected an option and clicked out of the selection element", async () => {
await waitFor(async () => {
await selectOption("Option 2");
const outsideButton = screen.getByText("Click Outside");
fireEvent.click(outsideButton);
});
expect(screen.getByRole("form")).toHaveFormValues({ test: "option2" });
});
});
describe("Tests the Select Component with isMulti", () => {
test("Should have the right behavior when it has the prop isMulti", async () => {
renderSelectWithForm({ isMulti: true });
await waitFor(async () => {
const optionText = options[1].label;
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
const option = screen.getByText(optionText);
fireEvent.click(option);
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
});
const option2Selected = screen.getByLabelText("Remove Option 2");
const option3Selected = screen.getAllByLabelText("Remove Option 3");
expect(option2Selected).toBeInTheDocument();
expect(option3Selected.length).toBeGreaterThan(0);
fireEvent.click(option2Selected);
expect(option2Selected).not.toBeInTheDocument();
});
});
describe("Tests the classes and CSS of the Select component", () => {
test("Should render classes correctly when isDisabled is true", async () => {
renderSelectWithForm({ isDisabled: true });
const singleValueEl = screen.getByText("Option 3");
const valueContainerEl = singleValueEl.parentElement;
const cotrolEl = valueContainerEl?.parentElement;
expect(cotrolEl).toHaveClass("bg-subtle");
});
test("Should render classes correctly when classNames props is passed", async () => {
renderSelectWithForm({ classNames });
const singleValueEl = screen.getByText("Option 3");
const valueContainerEl = singleValueEl.parentElement;
const cotrolEl = valueContainerEl?.parentElement;
const inputEl = screen.getByRole("combobox", { hidden: true }).parentElement;
expect(singleValueEl).toHaveClass("w-1");
expect(valueContainerEl).toHaveClass("w-2");
expect(cotrolEl).toHaveClass("w-3");
expect(inputEl).toHaveClass("w-4");
await waitFor(async () => {
const optionText = options[1].label;
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText(optionText);
});
const optionEl = screen.getByText("Option 2").parentElement?.parentElement;
const menuListEl = optionEl?.parentElement;
const menuEl = menuListEl?.parentElement;
expect(optionEl).toHaveClass("w-5");
expect(menuListEl).toHaveClass("w-6");
expect(menuEl).toHaveClass("w-7");
});
test("Should render classes correctly for multiValue when classNames and isMulti props are passed and menu is open", async () => {
renderSelectWithForm({ classNames, isMulti: true });
const singleValueEl = screen.getByText("Option 3");
const multiValueEl = singleValueEl.parentElement;
expect(singleValueEl).not.toHaveClass("w-1");
expect(multiValueEl).toHaveClass("w-8");
await waitFor(async () => {
const optionText = options[1].label;
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
const option = screen.getByText(optionText);
fireEvent.click(option);
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
});
const option2 = screen.getByText(options[1].label);
const menuIsOpenEl = option2.parentElement?.parentElement?.nextSibling;
expect(menuIsOpenEl).toHaveClass(
"[&>*:last-child]:rotate-180 [&>*:last-child]:transition-transform "
);
});
test("Should render classes correctly when focused, selected and menu is open", async () => {
renderSelectWithForm();
await waitFor(async () => {
const optionText = options[1].label;
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
const option = screen.getByText(optionText);
option.focus();
});
const option1 = screen.getByText("Option 1");
const option1Parent = option1.parentElement?.parentElement;
const option3 = screen.getAllByText("Option 3");
const option3Parent = option3[1].parentElement?.parentElement;
const menuIsOpenEl = option3[0].parentElement?.nextSibling;
expect(option1).toBeInTheDocument();
expect(option1Parent).toHaveClass("bg-subtle");
expect(option3[1]).toBeInTheDocument();
expect(option3Parent).toHaveClass("bg-emphasis text-default");
expect(menuIsOpenEl).toHaveClass("rotate-180 transition-transform");
});
});
});
describe("Tests for SelectField component", () => {
const renderSelectField = (newProps?: any) => {
render(
<form aria-label="test-form">
<SelectField {...{ ...props, ...newProps }} />
</form>
);
};
test("Should render name as fallback label", () => {
renderSelectField();
expect(screen.getByText(props.name)).toBeInTheDocument();
});
test("Should not render the label element when label not passed and name is undefined", () => {
renderSelectField({ name: undefined });
expect(screen.queryByRole("label")).not.toBeInTheDocument();
});
test("Should render with the default value and label", () => {
renderSelectField({ label: "Test SelectField", name: "test" });
expect(screen.getByText("Option 3")).toBeInTheDocument();
expect(screen.getByRole("form")).toHaveFormValues({ test: "option3" });
const labelElement = screen.getByText("Test SelectField");
expect(labelElement).toBeInTheDocument();
});
});
describe("Tests for SelectWithValidation component", () => {
const handleSubmit = vi.fn((event) => {
event.preventDefault();
});
const renderSelectWithValidation = (required: boolean) => {
render(
<form onSubmit={handleSubmit} aria-label="test-form">
<label htmlFor="test">Test</label>
<SelectWithValidation {...props} required={required} inputId="test" />
<button type="submit">Submit</button>
</form>
);
};
test("Should render with the default value", () => {
renderSelectWithValidation(true);
expect(screen.getByText("Option 3")).toBeInTheDocument();
expect(screen.getByRole("form")).toHaveFormValues({ test: "option3" });
});
test("Should render an input element with the value passed as prop", () => {
renderSelectWithValidation(false);
expect(screen.getAllByDisplayValue("option3")).toHaveLength(1);
});
test("Should submit the form with the selected value after validation", async () => {
renderSelectWithValidation(true);
await waitFor(async () => {
await selectOption("Option 2");
const submitButton = screen.getByRole("button", { name: "Submit" });
fireEvent.click(submitButton);
});
expect(handleSubmit).toHaveBeenCalled();
});
test("Should fail to submit if nothing is selected", async () => {
renderSelectWithValidation(true);
const submitButton = screen.getByRole("button", { name: "Submit" });
fireEvent.click(submitButton);
expect(handleSubmit).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,26 @@
import type { GroupBase, SelectComponentsConfig, MenuPlacement } from "react-select";
import { InputComponent, OptionComponent, ControlComponent } from "./components";
export const getReactSelectProps = <
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
components,
menuPlacement = "auto",
}: {
components: SelectComponentsConfig<Option, IsMulti, Group>;
menuPlacement?: MenuPlacement;
}) => {
return {
menuPlacement,
components: {
Input: InputComponent,
Option: OptionComponent,
Control: ControlComponent,
...components,
},
unstyled: true,
};
};

View File

@@ -0,0 +1,35 @@
import React from "react";
import classNames from "@calcom/lib/classNames";
type Props = {
steps: number;
currentStep: number;
};
// It might be worth passing this label string from outside the component so we can translate it?
function FormStep({ currentStep, steps }: Props) {
return (
<div className="w-full">
<p className="text-muted text-xs font-medium">
Step {currentStep} of {steps}
</p>
<div className="flex flex-nowrap space-x-1">
{[...Array(steps)].map((_, j) => {
console.log({ j, currentStep });
return (
<div
className={classNames(
"h-1 w-full rounded-sm",
currentStep - 1 >= j ? "bg-black" : "bg-gray-400"
)}
key={j}
/>
);
})}
</div>
</div>
);
}
export default FormStep;

View File

@@ -0,0 +1,64 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
type DefaultStep = {
title: string;
};
function Stepper<T extends DefaultStep>(props: {
href: string;
step: number;
steps: T[];
disableSteps?: boolean;
stepLabel?: (currentStep: number, totalSteps: number) => string;
}) {
const {
href,
steps,
stepLabel = (currentStep, totalSteps) => `Step ${currentStep} of ${totalSteps}`,
} = props;
const [stepperRef] = useAutoAnimate<HTMLOListElement>();
return (
<>
{steps.length > 1 && (
<nav className="flex items-center justify-center" aria-label="Progress">
<p className="text-sm font-medium">{stepLabel(props.step, steps.length)}</p>
<ol role="list" className="ml-8 flex items-center space-x-5" ref={stepperRef}>
{steps.map((mapStep, index) => (
<li key={mapStep.title}>
<Link
href={props.disableSteps ? "#" : `${href}?step=${index + 1}`}
shallow
replace
legacyBehavior>
{index + 1 < props.step ? (
<a className="hover:bg-inverted block h-2.5 w-2.5 rounded-full bg-gray-600">
<span className="sr-only">{mapStep.title}</span>
</a>
) : index + 1 === props.step ? (
<a className="relative flex items-center justify-center" aria-current="step">
<span className="absolute flex h-5 w-5 p-px" aria-hidden="true">
<span className="bg-emphasis h-full w-full rounded-full" />
</span>
<span
className="relative block h-2.5 w-2.5 rounded-full bg-gray-600"
aria-hidden="true"
/>
<span className="sr-only">{mapStep.title}</span>
</a>
) : (
<a className="bg-emphasis block h-2.5 w-2.5 rounded-full hover:bg-gray-400">
<span className="sr-only">{mapStep.title}</span>
</a>
)}
</Link>
</li>
))}
</ol>
</nav>
)}
</>
);
}
export default Stepper;

View File

@@ -0,0 +1,57 @@
import classNames from "@calcom/lib/classNames";
type StepWithNav = {
maxSteps: number;
currentStep: number;
navigateToStep: (step: number) => void;
disableNavigation?: false;
stepLabel?: (currentStep: number, maxSteps: number) => string;
};
type StepWithoutNav = {
maxSteps: number;
currentStep: number;
navigateToStep?: undefined;
disableNavigation: true;
stepLabel?: (currentStep: number, maxSteps: number) => string;
};
// Discriminative union on disableNavigation prop
type StepsProps = StepWithNav | StepWithoutNav;
const Steps = (props: StepsProps) => {
const {
maxSteps,
currentStep,
navigateToStep,
disableNavigation = false,
stepLabel = (currentStep, totalSteps) => `Step ${currentStep} of ${totalSteps}`,
} = props;
return (
<div className="mt-6 space-y-2">
<p className="text-subtle text-xs font-medium">{stepLabel(currentStep, maxSteps)}</p>
<div data-testid="step-indicator-container" className="flex w-full space-x-2 rtl:space-x-reverse">
{new Array(maxSteps).fill(0).map((_s, index) => {
return index <= currentStep - 1 ? (
<div
key={`step-${index}`}
onClick={() => navigateToStep?.(index)}
className={classNames(
"bg-inverted h-1 w-full rounded-[1px]",
index < currentStep - 1 && !disableNavigation ? "cursor-pointer" : ""
)}
data-testid={`step-indicator-${index}`}
/>
) : (
<div
key={`step-${index}`}
className="bg-emphasis h-1 w-full rounded-[1px] opacity-25"
data-testid={`step-indicator-${index}`}
/>
);
})}
</div>
</div>
);
};
export { Steps };

View File

@@ -0,0 +1,3 @@
export { default as FormStep } from "./FormStep";
export { Steps } from "./Steps";
export { default as Stepper } from "./Stepper";

View File

@@ -0,0 +1,43 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import { Title, VariantRow, VariantsTable, CustomArgsTable } from "@calcom/storybook/components";
import { Steps } from "./Steps";
<Meta title="UI/Form/Steps" component={Steps} />
<Title title="Steps" suffix="Brief" subtitle="Version 1.0 — Last Update: 15 Aug 2023" />
## Definition
Steps component is used to display the current step out of the total steps in a process.
## Structure
The `Steps` component can be used to show the steps of the total in a process.
<CustomArgsTable of={Steps} />
## Steps Story
<Canvas>
<Story
name="Steps"
args={{ maxSteps: 4, currentStep: 2 }}
argTypes={{ maxSteps: { control: "number" }, currentStep: { control: "number" } }}>
{({ maxSteps, currentStep }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<Steps
maxSteps={maxSteps}
currentStep={currentStep}
navigateToStep={(step) => {
const newPath = `?path=/story/ui-form-steps--steps&args=currentStep:${step + 1}`;
window.open(newPath, "_self");
}}
/>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,62 @@
/* eslint-disable playwright/no-conditional-in-test */
/* eslint-disable playwright/missing-playwright-await */
import { render, fireEvent } from "@testing-library/react";
import { vi } from "vitest";
import { Steps } from "./Steps";
const MAX_STEPS = 10;
const CURRENT_STEP = 5;
const mockNavigateToStep = vi.fn();
const Props = {
maxSteps: MAX_STEPS,
currentStep: CURRENT_STEP,
navigateToStep: mockNavigateToStep,
stepLabel: (currentStep: number, totalSteps: number) => `Test Step ${currentStep} of ${totalSteps}`,
};
describe("Tests for Steps Component", () => {
test("Should render the correct number of steps", () => {
const { queryByTestId } = render(<Steps {...Props} />);
const stepIndicatorDivs = queryByTestId("step-indicator-container");
const childDivs = stepIndicatorDivs?.querySelectorAll("div");
const count = childDivs?.length;
expect(stepIndicatorDivs).toBeInTheDocument();
expect(count).toBe(MAX_STEPS);
for (let i = 0; i < MAX_STEPS; i++) {
const step = queryByTestId(`step-indicator-${i}`);
if (i < CURRENT_STEP - 1) {
expect(step).toHaveClass("cursor-pointer");
} else {
expect(step).not.toHaveClass("cursor-pointer");
}
}
});
test("Should render correctly the label of the steps", () => {
const { getByText } = render(<Steps {...Props} />);
expect(getByText(`Test Step ${CURRENT_STEP} of ${MAX_STEPS}`)).toBeInTheDocument();
});
test("Should navigate to the correct step when clicked", async () => {
const { getByTestId } = render(<Steps {...Props} />);
for (let i = 0; i < MAX_STEPS; i++) {
const stepIndicator = getByTestId(`step-indicator-${i}`);
if (i < CURRENT_STEP - 1) {
fireEvent.click(stepIndicator);
expect(mockNavigateToStep).toHaveBeenCalledWith(i);
mockNavigateToStep.mockClear();
} else {
expect(mockNavigateToStep).not.toHaveBeenCalled();
}
}
});
});

View File

@@ -0,0 +1,121 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { ReactNode } from "react";
import { classNames } from "@calcom/lib";
import { Label } from "..";
import Switch from "./Switch";
type Props = {
children?: ReactNode;
title: string;
description?: string | React.ReactNode;
checked: boolean;
disabled?: boolean;
LockedIcon?: React.ReactNode;
Badge?: React.ReactNode;
onCheckedChange?: (checked: boolean) => void;
"data-testid"?: string;
tooltip?: string;
toggleSwitchAtTheEnd?: boolean;
childrenClassName?: string;
switchContainerClassName?: string;
labelClassName?: string;
descriptionClassName?: string;
};
function SettingsToggle({
checked,
onCheckedChange,
description,
LockedIcon,
Badge,
title,
children,
disabled,
tooltip,
toggleSwitchAtTheEnd = false,
childrenClassName,
switchContainerClassName,
labelClassName,
descriptionClassName,
...rest
}: Props) {
const [animateRef] = useAutoAnimate<HTMLDivElement>();
return (
<>
<div className="flex w-full flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<fieldset className="block w-full flex-col sm:flex">
{toggleSwitchAtTheEnd ? (
<div
className={classNames(
"border-subtle flex justify-between space-x-3 rounded-lg border px-4 py-6 sm:px-6",
checked && children && "rounded-b-none",
switchContainerClassName
)}>
<div>
<div className="flex items-center gap-x-2" data-testid={`${rest["data-testid"]}-title`}>
<Label
className={classNames("mt-0.5 text-base font-semibold leading-none", labelClassName)}
htmlFor="">
{title}
{LockedIcon}
</Label>
{Badge && <div className="mb-2">{Badge}</div>}
</div>
{description && (
<p
className={classNames(
"text-default -mt-1.5 text-sm leading-normal",
descriptionClassName
)}
data-testid={`${rest["data-testid"]}-description`}>
{description}
</p>
)}
</div>
<div className="my-auto h-full">
<Switch
data-testid={rest["data-testid"]}
fitToHeight={true}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
tooltip={tooltip}
/>
</div>
</div>
) : (
<div className="flex space-x-3">
<Switch
data-testid={rest["data-testid"]}
fitToHeight={true}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
tooltip={tooltip}
/>
<div>
<Label
className={classNames("text-emphasis text-sm font-semibold leading-none", labelClassName)}>
{title}
{LockedIcon}
</Label>
{description && <p className="text-default -mt-1.5 text-sm leading-normal">{description}</p>}
</div>
</div>
)}
{children && (
<div className={classNames("lg:ml-14", childrenClassName)} ref={animateRef}>
{checked && <div className={classNames(!toggleSwitchAtTheEnd && "mt-4")}>{children}</div>}
</div>
)}
</fieldset>
</div>
</>
);
}
export default SettingsToggle;

View File

@@ -0,0 +1,82 @@
import { useId } from "@radix-ui/react-id";
import * as Label from "@radix-ui/react-label";
import * as PrimitiveSwitch from "@radix-ui/react-switch";
import type { ReactNode } from "react";
import React from "react";
import cx from "@calcom/lib/classNames";
import { Tooltip } from "../../tooltip";
const Wrapper = ({ children, tooltip }: { tooltip?: string; children: React.ReactNode }) => {
if (!tooltip) {
return <>{children}</>;
}
return <Tooltip content={tooltip}>{children}</Tooltip>;
};
const Switch = (
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
label?: string | ReactNode;
fitToHeight?: boolean;
disabled?: boolean;
tooltip?: string;
small?: boolean;
labelOnLeading?: boolean;
classNames?: {
container?: string;
thumb?: string;
};
LockedIcon?: React.ReactNode;
}
) => {
const { label, fitToHeight, classNames, small, labelOnLeading, LockedIcon, ...primitiveProps } = props;
const id = useId();
const isChecked = props.checked || props.defaultChecked;
return (
<Wrapper tooltip={props.tooltip}>
<div
className={cx(
"flex h-auto w-auto flex-row items-center",
fitToHeight && "h-fit",
labelOnLeading && "flex-row-reverse",
classNames?.container
)}>
{LockedIcon && <div className="mr-2">{LockedIcon}</div>}
<PrimitiveSwitch.Root
className={cx(
isChecked ? "bg-brand-default dark:bg-brand-emphasis" : "bg-emphasis",
primitiveProps.disabled && "cursor-not-allowed",
small ? "h-4 w-[27px]" : "h-5 w-[34px]",
"focus:ring-brand-default rounded-full shadow-none transition focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1",
props.className
)}
{...primitiveProps}>
<PrimitiveSwitch.Thumb
id={id}
className={cx(
small
? "h-[10px] w-[10px] ltr:translate-x-[2px] rtl:-translate-x-[2px] ltr:[&[data-state='checked']]:translate-x-[13px] rtl:[&[data-state='checked']]:-translate-x-[13px]"
: "h-[14px] w-[14px] ltr:translate-x-[4px] rtl:-translate-x-[4px] ltr:[&[data-state='checked']]:translate-x-[17px] rtl:[&[data-state='checked']]:-translate-x-[17px]",
"block rounded-full transition will-change-transform",
isChecked ? "bg-brand-accent shadow-inner" : "bg-default",
classNames?.thumb
)}
/>
</PrimitiveSwitch.Root>
{label && (
<Label.Root
htmlFor={id}
className={cx(
"text-emphasis ms-2 align-text-top text-sm font-medium",
primitiveProps.disabled ? "cursor-not-allowed opacity-25" : "cursor-pointer",
labelOnLeading && "flex-1"
)}>
{label}
</Label.Root>
)}
</div>
</Wrapper>
);
};
export default Switch;

View File

@@ -0,0 +1,2 @@
export { default as SettingsToggle } from "./SettingsToggle";
export { default as Switch } from "./Switch";

View File

@@ -0,0 +1,97 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Title,
VariantsTable,
CustomArgsTable,
VariantRow,
} from "@calcom/storybook/components";
import Switch from "./Switch";
<Meta title="UI/Form/Switch" component={Switch} />
<Title title="Switch" suffix="Brief" subtitle="Version 1.0 — Last Update: 16 Aug 2023" />
## Definition
Switch is a customizable toggle switch component that allows users to change between two states.
## Structure
The `Switch` component can be used to create toggle switches for various purposes. It provides options for adding labels, icons, and tooltips.
<CustomArgsTable of={Switch} />
<Examples title="States">
<Example title="Default">
<Switch />
</Example>
<Example title="Disabled">
<Switch disabled />
</Example>
<Example title="Checked">
<Switch checked />
</Example>
</Examples>
<Examples title="Labels">
<Example title="With Label and labelOnLeading">
<Switch label="Enable Feature" labelOnLeading />
</Example>
<Example title="With Label">
<Switch label="Enable Feature" />
</Example>
</Examples>
<Examples title="Hover">
<Example title="With Tooltip (Hover me)">
<TooltipProvider>
<Switch tooltip="Toggle to enable/disable the feature" />
</TooltipProvider>
</Example>
<Example title="Without Tooltip (Hover me)">
<TooltipProvider>
<Switch />
</TooltipProvider>
</Example>
</Examples>
<Title offset title="Switch" suffix="Variants" />
<Canvas>
<Story
name="Switch"
args={{
label: "Enable Feature",
tooltip: "Toggle to enable/disable the feature",
checked: false,
disabled: false,
fitToHeight: false,
labelOnLeading: false,
}}
argTypes={{
label: { control: { type: "text" } },
tooltip: { control: { type: "text" } },
checked: { control: { type: "boolean" } },
disabled: { control: { type: "boolean" } },
fitToHeight: { control: { type: "boolean" } },
labelOnLeading: { control: { type: "boolean" } },
}}>
{(props) => (
<TooltipProvider>
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<Switch
{...props}
onCheckedChange={(checkedValue) => console.log("Switch value:", checkedValue)}
/>
</VariantRow>
</VariantsTable>
</TooltipProvider>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,146 @@
import { useMemo, useState } from "react";
import type { ITimezoneOption, ITimezone, Props as SelectProps } from "react-timezone-select";
import BaseSelect from "react-timezone-select";
import { classNames } from "@calcom/lib";
import { CALCOM_VERSION } from "@calcom/lib/constants";
import { filterByCities, addCitiesToDropdown, handleOptionLabel } from "@calcom/lib/timezone";
import { trpc } from "@calcom/trpc/react";
import { getReactSelectProps } from "../select";
export interface ICity {
city: string;
timezone: string;
}
export type TimezoneSelectProps = SelectProps & {
variant?: "default" | "minimal";
timezoneSelectCustomClassname?: string;
};
export function TimezoneSelect(props: TimezoneSelectProps) {
const { data, isPending } = trpc.viewer.timezones.cityTimezones.useQuery(
{
CalComVersion: CALCOM_VERSION,
},
{
trpc: { context: { skipBatch: true } },
}
);
return <TimezoneSelectComponent data={data} isPending={isPending} {...props} />;
}
export type TimezoneSelectComponentProps = SelectProps & {
variant?: "default" | "minimal";
isPending: boolean;
data: ICity[] | undefined;
timezoneSelectCustomClassname?: string;
};
export function TimezoneSelectComponent({
className,
classNames: timezoneClassNames,
timezoneSelectCustomClassname,
components,
variant = "default",
data,
isPending,
value,
...props
}: TimezoneSelectComponentProps) {
const [cities, setCities] = useState<ICity[]>([]);
const handleInputChange = (tz: string) => {
if (data) setCities(filterByCities(tz, data));
};
const reactSelectProps = useMemo(() => {
return getReactSelectProps({
components: components || {},
});
}, [components]);
return (
<BaseSelect
value={value}
className={`${className} ${timezoneSelectCustomClassname}`}
isLoading={isPending}
isDisabled={isPending}
{...reactSelectProps}
timezones={{
...(data ? addCitiesToDropdown(data) : {}),
...addCitiesToDropdown(cities),
}}
onInputChange={handleInputChange}
{...props}
formatOptionLabel={(option) => (
<p className="truncate">{(option as ITimezoneOption).value.replace(/_/g, " ")}</p>
)}
getOptionLabel={(option) => handleOptionLabel(option as ITimezoneOption, cities)}
classNames={{
...timezoneClassNames,
input: (state) =>
classNames(
"text-emphasis h-6 md:max-w-[145px] max-w-[250px]",
timezoneClassNames?.input && timezoneClassNames.input(state)
),
option: (state) =>
classNames(
"bg-default flex !cursor-pointer justify-between py-2.5 px-3 rounded-none text-default ",
state.isFocused && "bg-subtle",
state.isSelected && "bg-emphasis",
timezoneClassNames?.option && timezoneClassNames.option(state)
),
placeholder: (state) => classNames("text-muted", state.isFocused && "hidden"),
dropdownIndicator: () => "text-default",
control: (state) =>
classNames(
"!cursor-pointer",
variant === "default"
? "px-3 py-2 bg-default border-default !min-h-9 text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-2 focus-within:ring-emphasis hover:border-emphasis rounded-md border gap-1"
: "text-sm gap-1",
timezoneClassNames?.control && timezoneClassNames.control(state)
),
singleValue: (state) =>
classNames(
"text-emphasis placeholder:text-muted",
timezoneClassNames?.singleValue && timezoneClassNames.singleValue(state)
),
valueContainer: (state) =>
classNames(
"text-emphasis placeholder:text-muted flex gap-1",
timezoneClassNames?.valueContainer && timezoneClassNames.valueContainer(state)
),
multiValue: (state) =>
classNames(
"bg-subtle text-default rounded-md py-1.5 px-2 flex items-center text-sm leading-none",
timezoneClassNames?.multiValue && timezoneClassNames.multiValue(state)
),
menu: (state) =>
classNames(
"rounded-md bg-default text-sm leading-4 text-default mt-1 border border-subtle",
state.selectProps.menuIsOpen && "shadow-dropdown", // Add box-shadow when menu is open
timezoneClassNames?.menu && timezoneClassNames.menu(state)
),
groupHeading: () => "leading-none text-xs uppercase text-default pl-2.5 pt-4 pb-2",
menuList: (state) =>
classNames(
"scroll-bar scrollbar-track-w-20 rounded-md",
timezoneClassNames?.menuList && timezoneClassNames.menuList(state)
),
indicatorsContainer: (state) =>
classNames(
state.selectProps.menuIsOpen
? state.isMulti
? "[&>*:last-child]:rotate-180 [&>*:last-child]:transition-transform"
: "rotate-180 transition-transform"
: "text-default", // Woo it adds another SVG here on multi for some reason
timezoneClassNames?.indicatorsContainer && timezoneClassNames.indicatorsContainer(state)
),
multiValueRemove: () => "text-default py-auto ml-2",
noOptionsMessage: () => "h-12 py-2 flex items-center justify-center",
}}
/>
);
}
export type { ITimezone, ITimezoneOption };

View File

@@ -0,0 +1,8 @@
export { TimezoneSelect, TimezoneSelectComponent } from "./TimezoneSelect";
export type {
ITimezone,
ITimezoneOption,
ICity,
TimezoneSelectProps,
TimezoneSelectComponentProps,
} from "./TimezoneSelect";

View File

@@ -0,0 +1,174 @@
/* eslint-disable playwright/missing-playwright-await */
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Props as SelectProps } from "react-timezone-select";
import { vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { TimezoneSelect } from "./TimezoneSelect";
const cityTimezonesMock = [
{ city: "Dawson City", timezone: "America/Dawson" },
{ city: "Honolulu", timezone: "Pacific/Honolulu" },
{ city: "Juneau", timezone: "America/Juneau" },
{ city: "Toronto", timezone: "America/Toronto" },
];
const runtimeMock = async (isPending: boolean) => {
const updatedTrcp = {
viewer: {
timezones: {
cityTimezones: {
useQuery() {
return {
data: cityTimezonesMock,
isPending,
};
},
},
},
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockedLib = (await import("@calcom/trpc/react")) as any;
mockedLib.trpc = updatedTrcp;
};
const formatOffset = (offset: string) =>
offset.replace(/^([-+])(0)(\d):00$/, (_, sign, _zero, hour) => `${sign}${hour}:00`);
const formatTimeZoneWithOffset = (timeZone: string) =>
`${timeZone} GMT ${formatOffset(dayjs.tz(undefined, timeZone).format("Z"))}`;
const timezoneMockValues = ["America/Dawson", "Pacific/Honolulu", "America/Juneau", "America/Toronto"];
const optionMockValues = timezoneMockValues.map(formatTimeZoneWithOffset);
const classNames = {
singleValue: () => "test1",
valueContainer: () => "test2",
control: () => "test3",
input: () => "test4",
option: () => "test5",
menuList: () => "test6",
menu: () => "test7",
multiValue: () => "test8",
};
const onChangeMock = vi.fn();
const renderSelect = (newProps: SelectProps & { variant?: "default" | "minimal" }) => {
render(
<form aria-label="test-form">
<label htmlFor="test">Test</label>
<TimezoneSelect {...newProps} inputId="test" />
</form>
);
};
const openMenu = async () => {
await waitFor(async () => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText(optionMockValues[0]);
});
};
describe("Test TimezoneSelect", () => {
afterEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
});
describe("Test TimezoneSelect with isPending = false", () => {
beforeAll(async () => {
await runtimeMock(false);
});
test("Should render with the correct CSS when provided with classNames prop", async () => {
renderSelect({ value: timezoneMockValues[0], classNames });
openMenu();
const dawsonEl = screen.getByText(timezoneMockValues[0]);
expect(dawsonEl).toBeInTheDocument();
const singleValueEl = dawsonEl.parentElement;
const valueContainerEl = singleValueEl?.parentElement;
const controlEl = valueContainerEl?.parentElement;
const inputEl = screen.getByRole("combobox", { hidden: true }).parentElement;
const optionEl = screen.getByText(optionMockValues[0]).parentElement?.parentElement;
const menuListEl = optionEl?.parentElement;
const menuEl = menuListEl?.parentElement;
expect(singleValueEl).toHaveClass(classNames.singleValue());
expect(valueContainerEl).toHaveClass(classNames.valueContainer());
expect(controlEl).toHaveClass(classNames.control());
expect(inputEl).toHaveClass(classNames.input());
expect(optionEl).toHaveClass(classNames.option());
expect(menuListEl).toHaveClass(classNames.menuList());
expect(menuEl).toHaveClass(classNames.menu());
for (const mockText of optionMockValues) {
expect(screen.getByText(mockText)).toBeInTheDocument();
}
});
test("Should render with the correct CSS when provided with className prop", async () => {
renderSelect({ value: timezoneMockValues[0], className: "test-css" });
openMenu();
const labelTest = screen.getByText("Test");
const timezoneEl = labelTest.nextSibling;
expect(timezoneEl).toHaveClass("test-css");
});
test("Should render with the correct CSS when isMulti is enabled", async () => {
renderSelect({ value: timezoneMockValues[0], isMulti: true, classNames });
openMenu();
const dawsonEl = screen.getByText(timezoneMockValues[0]);
const multiValueEl = dawsonEl.parentElement?.parentElement;
expect(multiValueEl).toHaveClass(classNames.multiValue());
const inputEl = screen.getByRole("combobox", { hidden: true }).parentElement;
const menuIsOpenEl = inputEl?.parentElement?.nextSibling;
expect(menuIsOpenEl).toHaveClass("[&>*:last-child]:rotate-180 [&>*:last-child]:transition-transform ");
});
test("Should render with the correct CSS when menu is open and onChange is called", async () => {
renderSelect({ value: timezoneMockValues[0], onChange: onChangeMock });
await waitFor(async () => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText(optionMockValues[3]);
const inputEl = screen.getByRole("combobox", { hidden: true }).parentElement;
const menuIsOpenEl = inputEl?.parentElement?.nextSibling;
expect(menuIsOpenEl).toHaveClass("rotate-180 transition-transform ");
const opt = screen.getByText(optionMockValues[3]);
fireEvent.click(opt);
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
});
expect(onChangeMock).toBeCalled();
});
});
describe("Test TimezoneSelect with isPending = true", () => {
beforeAll(async () => {
await runtimeMock(true);
});
test("Should have no options when isPending is true", async () => {
renderSelect({ value: timezoneMockValues[0] });
await waitFor(async () => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
});
for (const mockText of optionMockValues) {
const optionEl = screen.queryByText(mockText);
expect(optionEl).toBeNull();
}
});
});
});

View File

@@ -0,0 +1,83 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
CustomArgsTable,
Examples,
Example,
Title,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { StorybookTrpcProvider } from "../../mocks/trpc";
import { TimezoneSelect } from "./TimezoneSelect";
<Meta title="UI/Form/TimezoneSelect" component={TimezoneSelect} />
<Title title="TimezoneSelect" suffix="Brief" subtitle="Version 1.0 — Last Update: 25 Aug 2023" />
## Definition
The `TimezoneSelect` component is used to display timezone options.
## Structure
The `TimezoneSelect` component can be used to display timezone options.
<CustomArgsTable of={TimezoneSelect} />
## Examples
<Examples title="TimezoneSelect">
<Example title="Default">
<StorybookTrpcProvider>
<TimezoneSelect value="Africa/Douala" />
</StorybookTrpcProvider>
</Example>
<Example title="Disabled">
<StorybookTrpcProvider>
<TimezoneSelect value="Africa/Douala" isDisabled />
</StorybookTrpcProvider>
</Example>
</Examples>
## TimezoneSelect Story
<Canvas>
<Story
name="TimezoneSelect"
args={{
className: "mt-24",
value: "Africa/Douala",
variant: "default",
isDisabled: false,
timezones: {
"Timezone 1": "City 1",
"Timezone 2": "City 2",
"Timezone 3": "City 3",
},
isPending: false,
}}
argTypes={{
value: {
control: { disable: true },
},
variant: {
control: {
type: "inline-radio",
options: ["default", "minimal"],
},
},
}}>
{(args) => (
<VariantsTable titles={["Default"]} columnMinWidth={350}>
<VariantRow>
<StorybookTrpcProvider>
<TimezoneSelect {...args} />
</StorybookTrpcProvider>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,119 @@
import { useId } from "@radix-ui/react-id";
import { Root as ToggleGroupPrimitive, Item as ToggleGroupItemPrimitive } from "@radix-ui/react-toggle-group";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label } from "../../../components/form/inputs/Label";
const boolean = (yesNo: "yes" | "no") => (yesNo === "yes" ? true : yesNo === "no" ? false : undefined);
const yesNo = (boolean?: boolean) => (boolean === true ? "yes" : boolean === false ? "no" : undefined);
type VariantStyles = {
commonClass?: string;
toggleGroupPrimitiveClass?: string;
};
const getVariantStyles = (variant: string) => {
const variants: Record<string, VariantStyles> = {
default: {
commonClass: "px-4 w-full py-[10px]",
},
small: {
commonClass: "w-[49px] px-3 py-1.5",
toggleGroupPrimitiveClass: "space-x-1",
},
};
return variants[variant];
};
export const BooleanToggleGroup = function BooleanToggleGroup({
defaultValue = true,
value,
disabled = false,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onValueChange = () => {},
variant = "default",
...passThrough
}: {
defaultValue?: boolean;
value?: boolean;
onValueChange?: (value?: boolean) => void;
disabled?: boolean;
variant?: "default" | "small";
}) {
// Maintain a state because it is not necessary that onValueChange the parent component would re-render. Think react-hook-form
// Also maintain a string as boolean isn't accepted as ToggleGroupPrimitive value
const [yesNoValue, setYesNoValue] = useState<"yes" | "no" | undefined>(yesNo(value));
if (!yesNoValue) {
setYesNoValue(yesNo(defaultValue));
onValueChange(defaultValue);
return null;
}
const commonClass = classNames(
getVariantStyles(variant).commonClass,
"inline-flex items-center justify-center rounded text-sm font-medium leading-4",
disabled && "cursor-not-allowed"
);
const selectedClass = classNames(commonClass, "bg-emphasis text-emphasis");
const unselectedClass = classNames(commonClass, "text-default hover:bg-subtle hover:text-emphasis");
return (
<ToggleGroupPrimitive
value={yesNoValue}
type="single"
disabled={disabled}
className={classNames(
"border-subtle flex h-9 space-x-2 rounded-md border p-1 rtl:space-x-reverse",
getVariantStyles(variant).toggleGroupPrimitiveClass
)}
onValueChange={(yesNoValue: "yes" | "no") => {
setYesNoValue(yesNoValue);
onValueChange(boolean(yesNoValue));
}}
{...passThrough}>
<ToggleGroupItemPrimitive
className={classNames(boolean(yesNoValue) ? selectedClass : unselectedClass)}
disabled={disabled}
value="yes">
Yes
</ToggleGroupItemPrimitive>
<ToggleGroupItemPrimitive
disabled={disabled}
className={classNames(!boolean(yesNoValue) ? selectedClass : unselectedClass)}
value="no">
No
</ToggleGroupItemPrimitive>
</ToggleGroupPrimitive>
);
};
export const BooleanToggleGroupField = function BooleanToggleGroupField(
props: Parameters<typeof BooleanToggleGroup>[0] & {
label?: string;
containerClassName?: string;
name?: string;
labelProps?: React.ComponentProps<typeof Label>;
className?: string;
error?: string;
}
) {
const { t } = useLocale();
const { label = t(props.name || ""), containerClassName, labelProps, className, ...passThrough } = props;
const id = useId();
return (
<div className={classNames(containerClassName)}>
<div className={className}>
{!!label && (
<Label htmlFor={id} {...labelProps} className={classNames(props.error && "text-error", "mt-4")}>
{label}
</Label>
)}
</div>
<BooleanToggleGroup {...passThrough} />
</div>
);
};

View File

@@ -0,0 +1,77 @@
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
import type { ReactNode } from "react";
import { classNames } from "@calcom/lib";
import { Tooltip } from "@calcom/ui";
interface ToggleGroupProps extends Omit<RadixToggleGroup.ToggleGroupSingleProps, "type"> {
options: {
value: string;
label: string | ReactNode;
disabled?: boolean;
tooltip?: string;
iconLeft?: ReactNode;
}[];
isFullWidth?: boolean;
}
const OptionalTooltipWrapper = ({
children,
tooltipText,
}: {
children: ReactNode;
tooltipText?: ReactNode;
}) => {
if (tooltipText) {
return (
<Tooltip delayDuration={150} sideOffset={12} side="bottom" content={tooltipText}>
{children}
</Tooltip>
);
}
return <>{children}</>;
};
export const ToggleGroup = ({
options,
onValueChange,
isFullWidth,
customClassNames,
...props
}: ToggleGroupProps & { customClassNames?: string }) => {
return (
<>
<RadixToggleGroup.Root
type="single"
{...props}
onValueChange={onValueChange}
className={classNames(
`min-h-9 border-default bg-default relative inline-flex gap-0.5 rounded-md border p-1 rtl:flex-row-reverse`,
props.className,
isFullWidth && "w-full",
customClassNames
)}>
{options.map((option) => (
<OptionalTooltipWrapper key={option.value} tooltipText={option.tooltip}>
<RadixToggleGroup.Item
disabled={option.disabled}
value={option.value}
data-testid={`toggle-group-item-${option.value}`}
className={classNames(
"aria-checked:bg-emphasis relative rounded-[4px] px-3 py-1 text-sm leading-tight transition",
option.disabled
? "text-gray-400 hover:cursor-not-allowed"
: "text-default [&[aria-checked='false']]:hover:text-emphasis",
isFullWidth && "w-full"
)}>
<div className="item-center flex justify-center ">
{option.iconLeft && <span className="mr-2 flex h-4 w-4 items-center">{option.iconLeft}</span>}
{option.label}
</div>
</RadixToggleGroup.Item>
</OptionalTooltipWrapper>
))}
</RadixToggleGroup.Root>
</>
);
};

View File

@@ -0,0 +1,2 @@
export { ToggleGroup } from "./ToggleGroup";
export { BooleanToggleGroup, BooleanToggleGroupField } from "./BooleanToggleGroup";

View File

@@ -0,0 +1,108 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
CustomArgsTable,
Examples,
Example,
Title,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Icon } from "@calcom/ui";
import { ToggleGroup } from "./ToggleGroup";
<Meta title="UI/Form/ToggleGroup" component={ToggleGroup} />
<Title title="ToggleGroup" suffix="Brief" subtitle="Version 1.0 — Last Update: 17 Aug 2023" />
## Definition
The `ToggleGroup` component is used to create a group of toggle items with optional tooltips.
## Structure
<CustomArgsTable of={ToggleGroup} />
## Examples
<Examples title="Toggle Group With Icon Left">
<Example>
<TooltipProvider>
<ToggleGroup
options={[
{ value: "option1", label: "Option 1", tooltip: "Tooltip for Option 1", iconLeft: <Icon name="arrow-right" /> },
{ value: "option2", label: "Option 2", iconLeft: <Icon name="arrow-right" /> },
{ value: "option3", label: "Option 3", iconLeft: <Icon name="arrow-right" /> },
{
value: "option4",
label: "Option 4",
tooltip: "Tooltip for Option 4",
iconLeft: <Icon name="arrow-right" />,
},
{ value: "option5", label: "Option 5", iconLeft: <Icon name="arrow-right" />, disabled: true },
]}
/>
</TooltipProvider>
</Example>
</Examples>
## ToggleGroup Story
<Canvas>
<Story
name="Default"
args={{
options: [
{ value: "option1", label: "Option 1", tooltip: "Tooltip for Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
{
value: "option4",
label: "Option 4",
tooltip: "Tooltip for Option 4",
},
{ value: "option5", label: "Option 5", disabled: true },
],
}}
argTypes={{
options: {
value: {
control: {
type: "text",
},
},
lable: {
control: {
type: "text",
},
},
tooltip: {
control: {
type: "text",
},
},
disabled: {
control: {
type: "boolean",
},
},
isFullWidth: {
control: {
type: "boolean",
},
},
},
}}>
{({ options, isFullWidth }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<TooltipProvider>
<ToggleGroup options={options} isFullWidth={isFullWidth} />
</TooltipProvider>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,102 @@
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import { useRouter } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { Button, Steps } from "../../..";
type DefaultStep = {
title: string;
containerClassname?: string;
contentClassname?: string;
description: string;
content?: ((setIsPending: Dispatch<SetStateAction<boolean>>) => JSX.Element) | JSX.Element;
isEnabled?: boolean;
isPending?: boolean;
};
function WizardForm<T extends DefaultStep>(props: {
href: string;
steps: T[];
disableNavigation?: boolean;
containerClassname?: string;
prevLabel?: string;
nextLabel?: string;
finishLabel?: string;
stepLabel?: React.ComponentProps<typeof Steps>["stepLabel"];
}) {
const searchParams = useCompatSearchParams();
const { href, steps, nextLabel = "Next", finishLabel = "Finish", prevLabel = "Back", stepLabel } = props;
const router = useRouter();
const step = parseInt((searchParams?.get("step") as string) || "1");
const currentStep = steps[step - 1];
const setStep = (newStep: number) => {
router.replace(`${href}?step=${newStep || 1}`);
};
const [currentStepisPending, setCurrentStepisPending] = useState(false);
useEffect(() => {
setCurrentStepisPending(false);
}, [currentStep]);
return (
<div className="mx-auto mt-4 print:w-full" data-testid="wizard-form">
<div className={classNames("overflow-hidden md:mb-2 md:w-[700px]", props.containerClassname)}>
<div className="px-6 py-5 sm:px-14">
<h1 className="font-cal text-emphasis text-2xl" data-testid="step-title">
{currentStep.title}
</h1>
<p className="text-subtle text-sm" data-testid="step-description">
{currentStep.description}
</p>
{!props.disableNavigation && (
<Steps
maxSteps={steps.length}
currentStep={step}
navigateToStep={noop}
stepLabel={stepLabel}
data-testid="wizard-step-component"
/>
)}
</div>
</div>
<div className={classNames("mb-8 overflow-hidden md:w-[700px]", props.containerClassname)}>
<div className={classNames("print:p-none max-w-3xl px-8 py-5 sm:p-6", currentStep.contentClassname)}>
{typeof currentStep.content === "function"
? currentStep.content(setCurrentStepisPending)
: currentStep.content}
</div>
{!props.disableNavigation && (
<div className="flex justify-end px-4 py-4 print:hidden sm:px-6">
{step > 1 && (
<Button
color="secondary"
onClick={() => {
setStep(step - 1);
}}>
{prevLabel}
</Button>
)}
<Button
tabIndex={0}
loading={currentStepisPending}
type="submit"
color="primary"
form={`wizard-step-${step}`}
disabled={currentStep.isEnabled === false}
className="relative ml-2">
{step < steps.length ? nextLabel : finishLabel}
</Button>
</div>
)}
</div>
</div>
);
}
export default WizardForm;

View File

@@ -0,0 +1 @@
export { default as WizardForm } from "./WizardForm";

View File

@@ -0,0 +1,63 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import { CustomArgsTable, Title, VariantsTable, VariantRow } from "@calcom/storybook/components";
import WizardForm from "./WizardForm";
<Meta title="UI/Form/WizardForm" component={WizardForm} />
<Title title="WizardForm" subtitle="Version 1.0 — Last Update: 5 Sep 2023" />
## Definition
The `WizardForm` component provides a structure for creating multi-step forms or wizards.
## Structure
<CustomArgsTable of={WizardForm} />
## Note on Navigation
Please be aware that the steps navigation is managed internally within the `WizardForm` component. As such, when viewing this component in Storybook, clicking the "Next" button will not showcase the transition to the subsequent step.
To observe the actual step navigation behavior, please refer to the Storybook stories for the individual `Steps` component.
## WizardForm Story
<Canvas>
<Story
parameters={{
nextjs: {
appDirectory: true,
},
}}
name="Basic"
args={{
href: "/wizard",
steps: [
{ title: "Step 1", description: "Description for Step 1" },
{ title: "Step 2", description: "Description for Step 2" },
{ title: "Step 3", description: "Description for Step 3" },
],
}}
argTypes={{
href: {
control: {
type: "text",
},
},
steps: {
control: {
type: "object",
},
},
}}>
{({ href, steps }) => (
<VariantsTable titles={["Basic Wizard Form"]} columnMinWidth={150}>
<VariantRow>
<WizardForm href={href} steps={steps} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,144 @@
/* eslint-disable playwright/missing-playwright-await */
import { render, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import WizardForm from "./WizardForm";
vi.mock("@calcom/lib/hooks/useCompatSearchParams", () => ({
useCompatSearchParams() {
return { get: vi.fn().mockReturnValue(currentStepNavigation) };
},
}));
vi.mock("next/navigation", () => ({
useRouter() {
return { replace: vi.fn() };
},
useSearchParams() {
return { get: vi.fn().mockReturnValue(currentStepNavigation) };
},
}));
const steps = [
{
title: "Step 1",
description: "Description 1",
content: <p data-testid="content-1">Step 1</p>,
isEnabled: false,
},
{
title: "Step 2",
description: "Description 2",
content: (setIsPending: (value: boolean) => void) => (
<button data-testid="content-2" onClick={() => setIsPending(true)}>
Test
</button>
),
isEnabled: true,
},
{ title: "Step 3", description: "Description 3", content: <p data-testid="content-3">Step 3</p> },
];
const props = {
href: "/test/mock",
steps: steps,
nextLabel: "Next step",
prevLabel: "Previous step",
finishLabel: "Finish",
};
let currentStepNavigation: number;
const renderComponent = (extraProps?: { disableNavigation: boolean }) =>
render(<WizardForm {...props} {...extraProps} />);
describe("Tests for WizardForm component", () => {
test("Should handle all the steps correctly", async () => {
currentStepNavigation = 1;
const { queryByTestId, queryByText, rerender } = renderComponent();
const { prevLabel, nextLabel, finishLabel } = props;
const stepInfo = {
title: queryByTestId("step-title"),
description: queryByTestId("step-description"),
};
await waitFor(() => {
steps.forEach((step, index) => {
rerender(<WizardForm {...props} />);
const { title, description } = step;
const buttons = {
prev: queryByText(prevLabel),
next: queryByText(nextLabel),
finish: queryByText(finishLabel),
};
expect(stepInfo.title).toHaveTextContent(title);
expect(stepInfo.description).toHaveTextContent(description);
if (index === 0) {
// case of first step
expect(buttons.prev && buttons.finish).not.toBeInTheDocument();
expect(buttons.next).toBeInTheDocument();
} else if (index === steps.length - 1) {
// case of last step
expect(buttons.prev && buttons.finish).toBeInTheDocument();
expect(buttons.next).not.toBeInTheDocument();
} else {
// case of in-between steps
expect(buttons.prev && buttons.next).toBeInTheDocument();
expect(buttons.finish).not.toBeInTheDocument();
}
currentStepNavigation++;
});
});
});
describe("Should handle the visibility of the content", async () => {
test("Should render JSX content correctly", async () => {
currentStepNavigation = 1;
const { getByTestId, getByText } = renderComponent();
const currentStep = steps[0];
expect(getByTestId("content-1")).toBeInTheDocument();
expect(getByText(currentStep.title && currentStep.description)).toBeInTheDocument();
});
test("Should render function content correctly", async () => {
currentStepNavigation = 2;
const { getByTestId, getByText } = renderComponent();
const currentStep = steps[1];
expect(getByTestId("content-2")).toBeInTheDocument();
expect(getByText(currentStep.title && currentStep.description)).toBeInTheDocument();
});
});
test("Should disable 'Next step' button if current step navigation is not enabled", async () => {
currentStepNavigation = 1;
const { nextLabel } = props;
const { getByText } = renderComponent();
expect(getByText(nextLabel)).toBeDisabled();
});
test("Should handle when navigation is disabled", async () => {
const { queryByText, queryByTestId } = renderComponent({ disableNavigation: true });
const { prevLabel, nextLabel, finishLabel } = props;
const stepComponent = queryByTestId("wizard-step-component");
const stepInfo = {
title: queryByTestId("step-title"),
description: queryByTestId("step-description"),
};
const buttons = {
prev: queryByText(prevLabel),
next: queryByText(nextLabel),
finish: queryByText(finishLabel),
};
expect(stepInfo.title && stepInfo.description).toBeInTheDocument();
expect(stepComponent).not.toBeInTheDocument();
expect(buttons.prev && buttons.next && buttons.finish).not.toBeInTheDocument();
});
});