diff --git a/src/components/Icons/CheckIcon.tsx b/src/components/Icons/CheckIcon.tsx new file mode 100644 index 0000000..db6a5d4 --- /dev/null +++ b/src/components/Icons/CheckIcon.tsx @@ -0,0 +1,21 @@ +import { IconProps } from "@/components/Icons/types"; +import { FC } from "react"; + +export const CheckIcon: FC = ({ className, ...props }) => ( + + + +); + +CheckIcon.displayName = "CheckIcon"; diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 4cb7cb0..2b8551d 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -41,19 +41,20 @@ export interface InputProps extends ModifiedInputProps { icon?: FC; } -export const Input: FC = ({ - size = Size.Md, - radius = Radius.Sm, - type = InputType.Text, - className, +export const inputStyle = ({ disabled, - value, - onChange, error, - icon: Icon, - ...props -}) => { - const inputClasses = cn( + icon, + radius = Radius.Sm, + size = Size.Md, +}: { + disabled?: boolean; + error?: boolean; + icon?: boolean; + radius?: Radius; + size?: Size; +}) => + cn( "w-full", bgColorClass(BackgroundColor.Background), textColorClass(TextColor.Primary), @@ -75,7 +76,29 @@ export const Input: FC = ({ borderColorClass(BorderColor.Disabled, ElementState.Disabled), radiusStyles(radius), sizeStyles[size], - Icon ? paddingLeftStyles[size] : "pl-3", + icon ? paddingLeftStyles[size] : "pl-3" + ); + +export const Input: FC = ({ + size = Size.Md, + radius = Radius.Sm, + type = InputType.Text, + className, + disabled, + value, + onChange, + error, + icon: Icon, + ...props +}) => { + const inputClasses = cn( + inputStyle({ + disabled, + error, + icon: !!Icon, + radius, + size, + }), className ); diff --git a/src/components/Select/Option.tsx b/src/components/Select/Option.tsx new file mode 100644 index 0000000..a8e72d0 --- /dev/null +++ b/src/components/Select/Option.tsx @@ -0,0 +1,53 @@ +import type { FC, HTMLAttributes } from "react"; +import { ComboboxOption } from "@headlessui/react"; +import { Text } from "@/components/Text"; +import clsx from "clsx"; +import { + BackgroundColor, + bgColorClass, + ElementState, + TextColor, + textColorClass, +} from "@/types"; +import { CheckIcon } from "@/components/Icons/CheckIcon"; +import { cn } from "@/util/classes"; + +export interface OptionProps extends HTMLAttributes { + value?: any; + selected?: boolean; +} + +export const Option: FC = ({ + children, + className, + selected, + value, + ...props +}) => ( + +
+ {children} + + {selected && } + +
+
+); + +Option.displayName = "Option"; diff --git a/src/components/Select/Select.spec.tsx b/src/components/Select/Select.spec.tsx new file mode 100644 index 0000000..e1c97e5 --- /dev/null +++ b/src/components/Select/Select.spec.tsx @@ -0,0 +1,83 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { Select } from "./Select"; +import { ReactNode } from "react"; +import { Descriptor } from "@/types"; +import { randomString } from "#/testing-utils"; + +describe("Select", () => { + let testId: string; + let defaultProps: { + "data-testid": string; + options: Descriptor<{ label: string; content?: ReactNode }>[]; + }; + + beforeEach(() => { + testId = Math.random().toString(36).substring(2, 9); + defaultProps = { "data-testid": testId, options: [] }; + }); + + it("should render", () => { + render(); + + const input = within(screen.getByTestId(testId)).getByRole("combobox"); + + fireEvent.focus(input); + + defaultProps.options.forEach((opt) => + expect(screen.getByText(opt.data.label)).toBeInTheDocument() + ); + + fireEvent.blur(input); + + defaultProps.options.forEach((opt) => + expect(screen.queryByText(opt.data.label)).not.toBeInTheDocument() + ); + }); + + it("should not render options when not focused", () => { + render(); + + const input = within(screen.getByTestId(testId)).getByRole("combobox"); + + fireEvent.focus(input); + + defaultProps.options.forEach((_, i) => + expect(screen.getByTestId(testIds[i])).toBeInTheDocument() + ); + }); + }); +}); diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx new file mode 100644 index 0000000..63c0804 --- /dev/null +++ b/src/components/Select/Select.tsx @@ -0,0 +1,114 @@ +import { + FC, + HTMLAttributes, + ReactNode, + useCallback, + useEffect, + useState, +} from "react"; +import { Descriptor, Radius } from "@/types"; +import { Combobox, ComboboxInput, ComboboxOptions } from "@headlessui/react"; +import { Option } from "./Option"; +import { Text } from "@/components/Text"; +import radiusStyles from "@/styles/radius"; +import clsx from "clsx"; +import { inputStyle } from "@/components/Input"; + +export interface SelectProps extends Omit< + HTMLAttributes, + "onChange" +> { + disabled?: boolean; + exclusive?: boolean; + onChange?: (value: string | string[] | null) => void; + options?: Descriptor<{ label: string; content?: ReactNode }>[]; + value?: string | string[]; +} + +export const Select: FC = ({ + className, + disabled, + exclusive, + onChange, + options, + value, + ...props +}) => { + const [open, setOpen] = useState(false); + const [selectionState, setSelectionState] = useState(() => []); + + useEffect(() => { + if (value && value.length > 0) { + setSelectionState([...value]); + } + }, [value]); + + const handleChange = useCallback( + (value: string | string[] | null) => { + if (value) { + if (typeof value === "string") { + setSelectionState([value]); + } else { + setSelectionState([...value]); + } + } + + onChange?.(value); + }, + [onChange] + ); + + const getDisplayValue = useCallback( + (v: string | string[] | null): string => + v && v.length > 0 + ? typeof v === "string" + ? (options?.find((e) => e.id === v)?.data.label ?? "") + : v + .map((id) => options?.find((e) => e.id === id)?.data.label) + .filter((e) => !!e) + .join(", ") + : "", + [options] + ); + + return ( +
+ + setOpen(true)} + onBlur={() => setOpen(false)} + // We'd normally prefer to use `as={Input}`, + // but ref forwarding doesn't work here properly in react 18, + // which causes the dropdown menu to be anchored in the wrong place. + // Until we switch to react 19, + // we'll just style this component using the same classes as the `Input` component. + className={inputStyle({ disabled })} + /> + + + {options?.map((opt) => ( + + ))} + + +
+ ); +}; + +Select.displayName = "Select"; diff --git a/src/components/Select/index.ts b/src/components/Select/index.ts new file mode 100644 index 0000000..b6e8a07 --- /dev/null +++ b/src/components/Select/index.ts @@ -0,0 +1 @@ +export * from "./Select"; diff --git a/src/components/Stack/Stack.tsx b/src/components/Stack/Stack.tsx index 027b033..263ae7f 100644 --- a/src/components/Stack/Stack.tsx +++ b/src/components/Stack/Stack.tsx @@ -28,7 +28,8 @@ export const Stack: React.FC = ({ className={cn( "flex", orientation === Orientation.Column && "flex-col", - spacingStyles[spacing] + spacingStyles[spacing], + className )} {...props} > diff --git a/src/components/index.ts b/src/components/index.ts index 41ad241..50a1c32 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -12,6 +12,7 @@ export * from "./Pill"; export * from "./RichButton"; export * from "./RichButtonGroup"; export * from "./RichList"; +export * from "./Select"; export * from "./Spinner"; export * from "./Stack"; export * from "./Text";