Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/components/Icons/CheckIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { IconProps } from "@/components/Icons/types";
import { FC } from "react";

export const CheckIcon: FC<IconProps> = ({ className, ...props }) => (
<svg
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I previously added this Icon component to try to reduce the need to define the svg in every icon, it may need to be modified to change height/width: https://github.com/voxel51/design-system/blob/develop/src/components/Icons/Icon.tsx

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I had tried to use it, but it didn't seem to work correctly as a standalone icon (seems fine in the checkbox though). I didn't want to muck with the checkbox so added this one for now. Let's take another look once we have a good icon strategy

className={className}
xmlns="http://www.w3.org/2000/svg"
width="11"
height="8"
viewBox="0 0 11 8"
fill="none"
{...props}
>
<path
d="M3.52691 5.76093L9.34753 0.25656C9.5284 0.0855199 9.7586 0 10.0381 0C10.3176 0 10.5478 0.0855199 10.7287 0.25656C10.9096 0.4276 11 0.645287 11 0.909621C11 1.17396 10.9096 1.39164 10.7287 1.56268L4.21749 7.72012C4.02018 7.90671 3.78999 8 3.52691 8C3.26383 8 3.03363 7.90671 2.83632 7.72012L0.2713 5.29446C0.0904335 5.12342 0 4.90573 0 4.6414C0 4.37707 0.0904335 4.15938 0.2713 3.98834C0.452167 3.8173 0.682362 3.73178 0.961883 3.73178C1.24141 3.73178 1.4716 3.8173 1.65247 3.98834L3.52691 5.76093Z"
fill="currentColor"
/>
</svg>
);

CheckIcon.displayName = "CheckIcon";
47 changes: 35 additions & 12 deletions src/components/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,20 @@ export interface InputProps extends ModifiedInputProps {
icon?: FC<IconProps>;
}

export const Input: FC<InputProps> = ({
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),
Expand All @@ -75,7 +76,29 @@ export const Input: FC<InputProps> = ({
borderColorClass(BorderColor.Disabled, ElementState.Disabled),
radiusStyles(radius),
sizeStyles[size],
Icon ? paddingLeftStyles[size] : "pl-3",
icon ? paddingLeftStyles[size] : "pl-3"
);

export const Input: FC<InputProps> = ({
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
);

Expand Down
53 changes: 53 additions & 0 deletions src/components/Select/Option.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {
value?: any;
selected?: boolean;
}

export const Option: FC<OptionProps> = ({
children,
className,
selected,
value,
...props
}) => (
<ComboboxOption value={value} {...props}>
<div
className={clsx(
"flex flex-nowrap items-center justify-between",
"gap-x-md",
"py-2 px-3",
cn(
bgColorClass(BackgroundColor.Card1),
selected && bgColorClass(BackgroundColor.CardElevated)
),
bgColorClass(BackgroundColor.Card2, ElementState.Hover)
)}
>
<Text>{children}</Text>
<span
className={clsx(
"size-5 flex items-center",
textColorClass(TextColor.Secondary)
)}
>
{selected && <CheckIcon />}
</span>
</div>
</ComboboxOption>
);

Option.displayName = "Option";
83 changes: 83 additions & 0 deletions src/components/Select/Select.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<Select {...defaultProps} />);

expect(screen.getByTestId(testId)).toBeInTheDocument();
});

describe("with options", () => {
beforeEach(() => {
defaultProps.options = new Array(3)
.fill(0)
.map(() => ({ id: randomString(), data: { label: randomString() } }));
});

it("should render options on focus", () => {
render(<Select {...defaultProps} />);

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(<Select {...defaultProps} />);

defaultProps.options.forEach((opt) =>
expect(screen.queryByText(opt.data.label)).not.toBeInTheDocument()
);
});

it("should render custom content", () => {
const testIds: string[] = [];
defaultProps.options = new Array(3).fill(0).map(() => {
const contentId = randomString();
testIds.push(contentId);
return {
id: randomString(),
data: {
label: randomString(),
content: <div data-testid={contentId}></div>,
},
};
});

render(<Select {...defaultProps} />);

const input = within(screen.getByTestId(testId)).getByRole("combobox");

fireEvent.focus(input);

defaultProps.options.forEach((_, i) =>
expect(screen.getByTestId(testIds[i])).toBeInTheDocument()
);
});
});
});
114 changes: 114 additions & 0 deletions src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>,
"onChange"
> {
disabled?: boolean;
exclusive?: boolean;
onChange?: (value: string | string[] | null) => void;
options?: Descriptor<{ label: string; content?: ReactNode }>[];
value?: string | string[];
}

export const Select: FC<SelectProps> = ({
className,
disabled,
exclusive,
onChange,
options,
value,
...props
}) => {
const [open, setOpen] = useState<boolean>(false);
const [selectionState, setSelectionState] = useState<string[]>(() => []);

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 (
<div className={className} {...props}>
<Combobox
disabled={disabled}
value={value}
onChange={handleChange}
multiple={!exclusive}
>
<ComboboxInput
displayValue={getDisplayValue}
onFocus={() => 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 })}
/>

<ComboboxOptions
static={open}
anchor="bottom"
className={clsx("mt-1", radiusStyles(Radius.Md))}
>
{options?.map((opt) => (
<Option
key={opt.id}
value={opt.id}
selected={(value ?? selectionState).includes(opt.id)}
>
<Text>{opt.data.content ?? opt.data.label}</Text>
</Option>
))}
</ComboboxOptions>
</Combobox>
</div>
);
};

Select.displayName = "Select";
1 change: 1 addition & 0 deletions src/components/Select/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Select";
3 changes: 2 additions & 1 deletion src/components/Stack/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export const Stack: React.FC<StackProps> = ({
className={cn(
"flex",
orientation === Orientation.Column && "flex-col",
spacingStyles[spacing]
spacingStyles[spacing],
className
)}
{...props}
>
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down