first commit
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
115
calcom/packages/ui/components/form/checkbox/Checkbox.tsx
Normal file
115
calcom/packages/ui/components/form/checkbox/Checkbox.tsx
Normal 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 };
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
3
calcom/packages/ui/components/form/checkbox/index.ts
Normal file
3
calcom/packages/ui/components/form/checkbox/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Checkbox, CheckboxField } from "./Checkbox";
|
||||
export { default as MultiSelectCheckbox } from "./MultiSelectCheckboxes";
|
||||
export type { Option } from "./MultiSelectCheckboxes";
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const DateRangePickerLazy = dynamic(() =>
|
||||
import("./DateRangePicker").then((mod) => mod.DatePickerWithRange)
|
||||
);
|
||||
265
calcom/packages/ui/components/form/date-range-picker/styles.css
Normal file
265
calcom/packages/ui/components/form/date-range-picker/styles.css
Normal 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;
|
||||
}
|
||||
34
calcom/packages/ui/components/form/datepicker/DatePicker.tsx
Normal file
34
calcom/packages/ui/components/form/datepicker/DatePicker.tsx
Normal 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;
|
||||
@@ -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;
|
||||
1
calcom/packages/ui/components/form/datepicker/index.ts
Normal file
1
calcom/packages/ui/components/form/datepicker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DatePicker } from "./DatePicker";
|
||||
192
calcom/packages/ui/components/form/dropdown/Dropdown.tsx
Normal file
192
calcom/packages/ui/components/form/dropdown/Dropdown.tsx
Normal 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;
|
||||
361
calcom/packages/ui/components/form/dropdown/dropdown.stories.mdx
Normal file
361
calcom/packages/ui/components/form/dropdown/dropdown.stories.mdx
Normal 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>
|
||||
16
calcom/packages/ui/components/form/dropdown/index.ts
Normal file
16
calcom/packages/ui/components/form/dropdown/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
Dropdown,
|
||||
ButtonOrLink,
|
||||
DropdownItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuTriggerItem,
|
||||
} from "./Dropdown";
|
||||
53
calcom/packages/ui/components/form/index.ts
Normal file
53
calcom/packages/ui/components/form/index.ts
Normal 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";
|
||||
42
calcom/packages/ui/components/form/inputs/Form.tsx
Normal file
42
calcom/packages/ui/components/form/inputs/Form.tsx
Normal 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;
|
||||
122
calcom/packages/ui/components/form/inputs/HintOrErrors.tsx
Normal file
122
calcom/packages/ui/components/form/inputs/HintOrErrors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
calcom/packages/ui/components/form/inputs/Input.tsx
Normal file
200
calcom/packages/ui/components/form/inputs/Input.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
14
calcom/packages/ui/components/form/inputs/InputError.tsx
Normal file
14
calcom/packages/ui/components/form/inputs/InputError.tsx
Normal 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>
|
||||
);
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
15
calcom/packages/ui/components/form/inputs/Label.tsx
Normal file
15
calcom/packages/ui/components/form/inputs/Label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
calcom/packages/ui/components/form/inputs/TextField.tsx
Normal file
193
calcom/packages/ui/components/form/inputs/TextField.tsx
Normal 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} />;
|
||||
});
|
||||
182
calcom/packages/ui/components/form/inputs/input.test.tsx
Normal file
182
calcom/packages/ui/components/form/inputs/input.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
100
calcom/packages/ui/components/form/inputs/inputs.stories.mdx
Normal file
100
calcom/packages/ui/components/form/inputs/inputs.stories.mdx
Normal 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>
|
||||
27
calcom/packages/ui/components/form/inputs/types.d.ts
vendored
Normal file
27
calcom/packages/ui/components/form/inputs/types.d.ts
vendored
Normal 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 };
|
||||
209
calcom/packages/ui/components/form/select/Select.tsx
Normal file
209
calcom/packages/ui/components/form/select/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
calcom/packages/ui/components/form/select/components.tsx
Normal file
87
calcom/packages/ui/components/form/select/components.tsx
Normal 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 || <> </>}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
3
calcom/packages/ui/components/form/select/index.ts
Normal file
3
calcom/packages/ui/components/form/select/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { SelectWithValidation, SelectField, Select } from "./Select";
|
||||
|
||||
export { getReactSelectProps } from "./selectTheme";
|
||||
129
calcom/packages/ui/components/form/select/select.stories.mdx
Normal file
129
calcom/packages/ui/components/form/select/select.stories.mdx
Normal 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>
|
||||
308
calcom/packages/ui/components/form/select/select.test.tsx
Normal file
308
calcom/packages/ui/components/form/select/select.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
26
calcom/packages/ui/components/form/select/selectTheme.ts
Normal file
26
calcom/packages/ui/components/form/select/selectTheme.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
35
calcom/packages/ui/components/form/step/FormStep.tsx
Normal file
35
calcom/packages/ui/components/form/step/FormStep.tsx
Normal 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;
|
||||
64
calcom/packages/ui/components/form/step/Stepper.tsx
Normal file
64
calcom/packages/ui/components/form/step/Stepper.tsx
Normal 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;
|
||||
57
calcom/packages/ui/components/form/step/Steps.tsx
Normal file
57
calcom/packages/ui/components/form/step/Steps.tsx
Normal 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 };
|
||||
3
calcom/packages/ui/components/form/step/index.ts
Normal file
3
calcom/packages/ui/components/form/step/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FormStep } from "./FormStep";
|
||||
export { Steps } from "./Steps";
|
||||
export { default as Stepper } from "./Stepper";
|
||||
43
calcom/packages/ui/components/form/step/steps.stories.mdx
Normal file
43
calcom/packages/ui/components/form/step/steps.stories.mdx
Normal 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>
|
||||
62
calcom/packages/ui/components/form/step/steps.test.tsx
Normal file
62
calcom/packages/ui/components/form/step/steps.test.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
121
calcom/packages/ui/components/form/switch/SettingsToggle.tsx
Normal file
121
calcom/packages/ui/components/form/switch/SettingsToggle.tsx
Normal 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;
|
||||
82
calcom/packages/ui/components/form/switch/Switch.tsx
Normal file
82
calcom/packages/ui/components/form/switch/Switch.tsx
Normal 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;
|
||||
2
calcom/packages/ui/components/form/switch/index.ts
Normal file
2
calcom/packages/ui/components/form/switch/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SettingsToggle } from "./SettingsToggle";
|
||||
export { default as Switch } from "./Switch";
|
||||
97
calcom/packages/ui/components/form/switch/switch.stories.mdx
Normal file
97
calcom/packages/ui/components/form/switch/switch.stories.mdx
Normal 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>
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,8 @@
|
||||
export { TimezoneSelect, TimezoneSelectComponent } from "./TimezoneSelect";
|
||||
export type {
|
||||
ITimezone,
|
||||
ITimezoneOption,
|
||||
ICity,
|
||||
TimezoneSelectProps,
|
||||
TimezoneSelectComponentProps,
|
||||
} from "./TimezoneSelect";
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
2
calcom/packages/ui/components/form/toggleGroup/index.ts
Normal file
2
calcom/packages/ui/components/form/toggleGroup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ToggleGroup } from "./ToggleGroup";
|
||||
export { BooleanToggleGroup, BooleanToggleGroupField } from "./BooleanToggleGroup";
|
||||
@@ -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>
|
||||
102
calcom/packages/ui/components/form/wizard/WizardForm.tsx
Normal file
102
calcom/packages/ui/components/form/wizard/WizardForm.tsx
Normal 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;
|
||||
1
calcom/packages/ui/components/form/wizard/index.ts
Normal file
1
calcom/packages/ui/components/form/wizard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as WizardForm } from "./WizardForm";
|
||||
63
calcom/packages/ui/components/form/wizard/wizard.stories.mdx
Normal file
63
calcom/packages/ui/components/form/wizard/wizard.stories.mdx
Normal 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>
|
||||
144
calcom/packages/ui/components/form/wizard/wizardForm.test.tsx
Normal file
144
calcom/packages/ui/components/form/wizard/wizardForm.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user