Skip to content

Commit

Permalink
refactor: base implementation for non native select
Browse files Browse the repository at this point in the history
  • Loading branch information
abelflopes committed Jul 4, 2024
1 parent 6ed3c70 commit 1da6fb4
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 43 deletions.
4 changes: 3 additions & 1 deletion packages/components/select/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@
"react": "^18.2.0"
},
"dependencies": {
"@react-ck/empty-state": "^1.1.17",
"@react-ck/input": "^1.3.15",
"@react-ck/provisional": "^3.2.0",
"@react-ck/form-field": "^1.2.1",
"@react-ck/react-utils": "^1.2.12",
"@react-ck/scss-utils": "^1.1.10",
"@react-ck/text": "^1.5.1",
"@react-ck/theme": "^1.7.5",
Expand Down
177 changes: 148 additions & 29 deletions packages/components/select/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import React, { useEffect, useMemo } from "react";
import styles from "./styles/index.module.scss";
import classNames from "classnames";
import { FormField, useFormFieldContext, type FormFieldProps } from "@react-ck/form-field";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SelectOption } from "./SelectOption";
import { Dropdown, Menu } from "@react-ck/provisional";
import { Input, type InputProps } from "@react-ck/input";
import classNames from "classnames";
import { useNextRender, useOnClickOutside } from "@react-ck/react-utils";
import { EmptyState } from "@react-ck/empty-state";

const options = ["dog", "cat", "lion", "zebra", "shark"];

interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
skin?: FormFieldProps["skin"];
interface SelectProps extends Omit<React.HTMLAttributes<HTMLElement>, "onChange" | "value"> {
skin?: InputProps["skin"];
placeholder: InputProps["placeholder"];
children: React.ReactNode;
search?: {
placeholder: string;
emptyStateMessage: (value: string) => React.ReactNode;
};
onChange?: React.SelectHTMLAttributes<HTMLSelectElement>["onChange"];
value?: React.SelectHTMLAttributes<HTMLSelectElement>["value"];
multiple?: React.SelectHTMLAttributes<HTMLSelectElement>["multiple"];
}

/**
Expand All @@ -15,38 +29,143 @@ interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
* @returns a React element
*/

const Select = ({ skin, id, className, ...props }: Readonly<SelectProps>): React.ReactElement => {
const formFieldContext = useFormFieldContext();
const Select = ({
children,
className,
onFocus,
search: searchOptions,
onChange: selectOnChange,
value: selectValue,
multiple: selectMultiple,
...props
}: Readonly<SelectProps>): React.ReactElement => {
const searchRef = useRef<HTMLInputElement>(null);
const selectRef = useRef<HTMLSelectElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<string[] | undefined>(undefined);
const [search, setSearch] = useState("");

const onNextRender = useNextRender();

const filteredOptions = useMemo(
() => options.filter((i) => i.toLowerCase().includes(search.toLowerCase())),
[search],
);

const handleChange = useCallback(
(value: string) => {
setSelectedValues([value]);

setOpen(false);

onNextRender(() => {
if (!selectRef.current)
throw new Error("Select: unable to dispatch event to native element");

const computedSkin = useMemo(
() => formFieldContext?.skin ?? skin ?? "default",
[formFieldContext?.skin, skin],
selectRef.current.dispatchEvent(
new Event("change", {
bubbles: true,
}),
);
});
},
[onNextRender],
);

const computedId = useMemo(() => formFieldContext?.id ?? id, [formFieldContext?.id, id]);
useOnClickOutside(open, [dropdownRef, inputRef], () => {
setOpen(false);
});

// Validate usage inside form field
// Actions to do when dropdown opens
useEffect(() => {
// Is not inside form field, skip
if (formFieldContext === undefined) return;
if (!open) return;

// Is inside form field
if (skin) throw new Error("When using select inside form field, define skin on the form field");
else if (id)
throw new Error("When using select inside form field, define id on the form field");
}, [formFieldContext, id, skin]);
onNextRender(() => {
searchRef.current?.focus();
});
}, [onNextRender, open]);

// Actions to do when dropdown closes
useEffect(() => {
if (!open) return;

setSearch("");
}, [open]);

return (
<select
{...props}
id={computedId}
className={classNames(
styles.root,
formFieldContext === undefined && styles.standalone,
className,
styles[`skin_${computedSkin}`],
)}
/>
<>
<select
ref={selectRef}
multiple={selectMultiple}
onChange={selectOnChange}
value={selectedValues}>
{selectedValues?.map((i) => (
<option key={i} value={i}>
{i}
</option>
))}
</select>

<Input
{...props}
rootRef={inputRef}
className={classNames(styles.root, className)}
value={selectedValues?.join(", ") || ""}
readOnly
onFocus={(e) => {
setOpen(true);
onFocus?.(e);
}}
/>

<Dropdown
anchorRef={inputRef}
open={open}
spacing="none"
rootRef={dropdownRef}
excludeAutoPosition={["left", "right", "start", "end", "full"]}>
<Menu className={styles.menu}>
{searchOptions ? (
<>
<Input
rootRef={searchRef}
value={search}
type="search"
placeholder={searchOptions?.placeholder}
skin="ghost"
className={styles.search_input}
onChange={(e) => {
setSearch(e.target.value);
}}
/>
<Menu.Divider />
</>
) : null}

{children ? "" : ""}

{filteredOptions.map((i) => (
<Menu.Item
key={i}
skin={selectedValues?.includes(i) ? "primary" : "default"}
onClick={() => {
handleChange(i);
}}>
{i}
</Menu.Item>
))}

{searchOptions && filteredOptions.length === 0 ? (
<EmptyState>
<span>{searchOptions.emptyStateMessage(search)}</span>
{/* TODO: fix empty state flex */}
</EmptyState>
) : null}
</Menu>
</Dropdown>
</>
);
};

Expand Down
26 changes: 13 additions & 13 deletions packages/components/select/src/styles/index.module.scss
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
@use "@react-ck/theme";
@use "@react-ck/form-field";

// TODO: fix height offset between standalone and non-standalone (border)

.root {
@include form-field.form-field-input;
cursor: pointer;
}

// When not inside form field, has own border, etc
.standalone {
@include form-field.form-field-container;
.menu {
> :first-child {
border-top-left-radius: theme.get-spacing(1);
border-top-right-radius: theme.get-spacing(1);
}

@each $key, $props in form-field.$form-field-styles {
&.skin_#{$key} {
@each $propKey, $propValue in $props {
@include theme.define-css-var(form-field, $propKey, $propValue);
}
}
> :last-child {
border-bottom-left-radius: theme.get-spacing(1);
border-bottom-right-radius: theme.get-spacing(1);
}
}

.search_input {
outline: none !important;
}

0 comments on commit 1da6fb4

Please sign in to comment.