Skip to content

Commit

Permalink
feat: input multiselect adds inicial base
Browse files Browse the repository at this point in the history
  • Loading branch information
bearkfear committed Aug 7, 2024
1 parent 49f817d commit 28a0b9b
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 126 deletions.
28 changes: 26 additions & 2 deletions src/components/ui/form/form-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { cn } from "~/lib/utils";
import { HelperText } from "./helper-text";
import { Input } from "./input";
import { Label } from "./label";
import { Selector } from "./selector";
import { MultiSelector, SingleSelector } from "./selector";

function FormControl(props: FormRenderProps) {
const disabled = props.field.disabled || props.fieldConfig.disabled;
Expand All @@ -27,7 +27,7 @@ function FormControl(props: FormRenderProps) {

if (props.fieldConfig.type === "select") {
return (
<Selector
<SingleSelector
options={props.fieldConfig.options || []}
value={props.field.value}
onChange={props.field.onChange}
Expand All @@ -37,6 +37,30 @@ function FormControl(props: FormRenderProps) {
valuePath="value"
placeholder={props.fieldConfig.placeholder}
searchable={props.fieldConfig.searchable}
messages={{
empty: "Nenhuma opcao disponível",
searchPlaceholder: "Pesquisar por um item",
}}
/>
);
}

if (props.fieldConfig.type === "multi-select") {
return (
<MultiSelector
options={props.fieldConfig.options || []}
value={props.field.value}
onChange={props.field.onChange}
onBlur={props.field.onBlur}
disabled={disabled}
labelPath="label"
valuePath="value"
placeholder={props.fieldConfig.placeholder}
searchable={props.fieldConfig.searchable}
messages={{
empty: "Nenhuma opcao disponível",
searchPlaceholder: "Pesquisar por um item",
}}
/>
);
}
Expand Down
23 changes: 12 additions & 11 deletions src/components/ui/form/selector/content.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Check } from "lucide-react";
import { cn } from "~/lib/utils";
import * as Command from "../../command";
import * as Popover from "../../popover";
import type { SelectorCommonProps, TOption } from "./model";
import type { SelectorCommonProps, SelectorMessages, TOption } from "./model";

type SelectorContentProps<Option> = {
onSelect(option?: Option): void;
Expand All @@ -11,6 +9,7 @@ type SelectorContentProps<Option> = {
getIsSelect(option: Option): boolean;
options: Option[];
width: number;
message: SelectorMessages;
} & Pick<SelectorCommonProps, "searchable">;

export function SelectorContent<Option extends TOption>(
Expand All @@ -24,11 +23,16 @@ export function SelectorContent<Option extends TOption>(
>
<Command.Root>
{props.searchable && (
<Command.Input className="text-xs" placeholder="Search" />
<Command.Input
className="text-xs"
placeholder={props.message.searchPlaceholder}
/>
)}

<Command.List>
<Command.Empty className="text-xs">No language found.</Command.Empty>
<Command.Empty className="text-xs">
{props.message.empty}
</Command.Empty>
<Command.Group>
{props.options.map((option) => {
const optionValue = props.getValue(option);
Expand All @@ -40,12 +44,9 @@ export function SelectorContent<Option extends TOption>(
className="text-xs"
onSelect={() => props.onSelect(option)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
props.getIsSelect(option) ? "opacity-100" : "opacity-0",
)}
/>
{props.getIsSelect(option)
? props.message.optionSelected
: props.message.optionUnselected}
{props.getLabel(option)}
</Command.Item>
);
Expand Down
25 changes: 0 additions & 25 deletions src/components/ui/form/selector/hooks.ts

This file was deleted.

89 changes: 2 additions & 87 deletions src/components/ui/form/selector/index.tsx
Original file line number Diff line number Diff line change
@@ -1,87 +1,2 @@
import get from "lodash.get";
import isEqual from "lodash.isequal";
import { ChevronsUpDown } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import type { FieldPath } from "react-hook-form";
import { cn } from "~/lib/utils";
import { Button } from "../../button";
import * as Popover from "../../popover";
import { SelectorContent } from "./content";
import type { SelectorCommonProps, TOption } from "./model";
import { useSelectorHelpers } from "./hooks";

export interface SelectorProps<
O extends TOption,
VP extends FieldPath<O>,
TV = O[VP],
> extends SelectorCommonProps {
options: O[];
labelPath: FieldPath<O>;
valuePath: VP;
value: TV;
onChange?(value?: TV): void;
}

export function Selector<O extends TOption, VP extends FieldPath<O>>(
{ extraActions, onChange = () => {}, value, ...props }: SelectorProps<O, VP>,
// TODO: verificar onde por esse ref dentro do button
ref: React.ForwardedRef<HTMLButtonElement>,
) {
const [width, setWidth] = useState(1);
const { getLabel, getValue } = useSelectorHelpers<O>(
props.labelPath,
props.valuePath,
);

const item = (option: O) => get(option, props.labelPath);

const selectedOption = useMemo(() => {
return props.options.find((option) => isEqual(getValue(option), value));
}, [props.options, value, getValue]);

const onSelect = useCallback(
(option: O) => {
if (!onChange) return;

const optionValue = get(option, props.valuePath);

onChange(isEqual(optionValue, value) ? undefined : optionValue);
},
[onChange, props.valuePath, value],
);

const getIsSelected = useCallback(
(option: O) => isEqual(getValue(option), value),
[value, getValue],
);

return (
<Popover.Root>
<Popover.Trigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!selectedOption && "text-gray-9 dark:text-graydark-9",
)}
ref={(ref) => setWidth(ref?.getBoundingClientRect().width || 1)}
>
<span>
{selectedOption ? getLabel(selectedOption) : props.placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<SelectorContent
getLabel={getLabel}
getValue={getValue}
options={props.options}
onSelect={onSelect}
getIsSelect={getIsSelected}
searchable={props.searchable}
width={width}
/>
</Popover.Root>
);
}
export { SingleSelector, type SingleSelectorProps } from "./single-selector";
export { MultiSelector, type MultiSelectorProps } from "./multi-selector";
9 changes: 9 additions & 0 deletions src/components/ui/form/selector/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HTMLProps } from "react";
import type { ReactNode } from "react";

export type DefaultHTMLSelectInput = Omit<
HTMLProps<HTMLSelectElement>,
Expand All @@ -8,9 +9,17 @@ export type DefaultHTMLSelectInput = Omit<
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
export type TOption = Record<string, any>;

export type SelectorMessages = {
empty: ReactNode;
searchPlaceholder: string;
optionSelected: ReactNode;
optionUnselected: ReactNode;
};

export interface SelectorCommonProps extends DefaultHTMLSelectInput {
disabled?: boolean;
searchable?: boolean;
extraActions?: React.ReactNode;
hideFooter?: boolean;
messages: Pick<SelectorMessages, "empty" | "searchPlaceholder">;
}
113 changes: 113 additions & 0 deletions src/components/ui/form/selector/multi-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import get from "lodash.get";
import isEqual from "lodash.isequal";
import { Check, CheckSquare2, ChevronsUpDown, Square } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import type { FieldPath } from "react-hook-form";
import { cn } from "~/lib/utils";
import { Button } from "../../button";
import * as Popover from "../../popover";
import { SelectorContent } from "./content";
import type { SelectorCommonProps, TOption } from "./model";

export type MultiSelectorProps<
O extends TOption,
VP extends FieldPath<O>,
TV = O[VP],
> = SelectorCommonProps & {
options: O[];
labelPath: FieldPath<O>;
valuePath: VP;
value: TV[];
onChange?(value?: TV[]): void;
};

export function MultiSelector<O extends TOption, VP extends FieldPath<O>>(
{
extraActions,
onChange = () => {},
value = [],
...props
}: MultiSelectorProps<O, VP>,
// TODO: verificar onde por esse ref dentro do button
ref: React.ForwardedRef<HTMLButtonElement>,
) {
const [width, setWidth] = useState(1);
const getValue = useCallback(
(option: O) => {
return get(option, props.valuePath);
},
[props.valuePath],
);
const getLabel = useCallback(
(option: O) => {
return get(option, props.labelPath);
},
[props.labelPath],
);

const getIsSelected = useCallback(
(option: O) => {
return (value || []).some((it) => isEqual(getValue(option), it));
},
[value, getValue],
);

const selectedOptions = useMemo(() => {
return props.options.filter(getIsSelected);
}, [props.options, getIsSelected]);

const onSelect = useCallback(
(option: O) => {
if (!onChange) return;

const v = value || [];

const optionValue = get(option, props.valuePath);

if (value.includes(optionValue)) {
onChange(v.filter((it) => it !== optionValue));
return;
}

onChange(v.concat(optionValue));
},
[onChange, props.valuePath, value],
);

return (
<Popover.Root>
<Popover.Trigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!selectedOptions && "text-gray-9 dark:text-graydark-9",
)}
ref={(ref) => setWidth(ref?.getBoundingClientRect().width || 1)}
>
<span>
{selectedOptions
? selectedOptions.map((option) => getLabel(option)).join(", ")
: props.placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<SelectorContent
getLabel={getLabel}
getValue={getValue}
options={props.options}
onSelect={onSelect}
getIsSelect={getIsSelected}
searchable={props.searchable}
width={width}
message={{
...props.messages,
optionSelected: <CheckSquare2 className={cn("mr-2 h-4 w-4")} />,
optionUnselected: <Square className={cn("mr-2 h-4 w-4")} />,
}}
/>
</Popover.Root>
);
}
Loading

0 comments on commit 28a0b9b

Please sign in to comment.