Skip to content
This repository has been archived by the owner on Nov 9, 2021. It is now read-only.

Commit

Permalink
feat: initial suggester impl
Browse files Browse the repository at this point in the history
  • Loading branch information
Gabriel Pereira Woitechen committed Jul 1, 2020
1 parent ab0555c commit 7f9da73
Show file tree
Hide file tree
Showing 14 changed files with 1,034 additions and 29 deletions.
3 changes: 3 additions & 0 deletions .storybook/global.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
13 changes: 12 additions & 1 deletion .storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,24 @@ module.exports = {
stories: ["../src/stories/*.stories.tsx"],
addons: ["@storybook/addon-actions", "@storybook/addon-links"],
webpackFinal: async (config) => {
config.module.rules.forEach((rule) => {
if (rule.test.toString() === "/\\.css$/") {
const idx = rule.use.findIndex(
({ loader }) => loader && loader.includes("css-loader")
);
rule.use[idx].options.modules = true;
}
});
config.module.rules.push({
test: /\.css$/,
use: ["postcss-loader"],
});
config.module.rules.push({
test: /\.(ts|tsx)$/,
use: [
{
loader: require.resolve("ts-loader"),
},
// Optional
{
loader: require.resolve("react-docgen-typescript-loader"),
},
Expand Down
1 change: 1 addition & 0 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require("!style-loader!css-loader!postcss-loader!./global.css");
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"main": "index.js",
"license": "MIT",
"dependencies": {
"react": "16.13.1",
"react-dom": "16.13.1"
"clsx": "1.1.1",
"nanoid": "3.1.10",
"tailwindcss": "1.4.6"
},
"devDependencies": {
"@babel/core": "7.10.4",
Expand All @@ -15,13 +16,20 @@
"@storybook/addons": "5.3.19",
"@storybook/react": "5.3.19",
"@types/jest": "26.0.3",
"autoprefixer": "9.8.4",
"babel-loader": "8.1.0",
"cssnano": "4.1.10",
"jest": "26.1.0",
"postcss-loader": "3.0.0",
"react-docgen-typescript-loader": "3.7.2",
"ts-jest": "26.1.1",
"ts-loader": "7.0.5",
"typescript": "3.9.5"
},
"peerDependencies": {
"react": "16.13.1",
"react-dom": "16.13.1"
},
"scripts": {
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
Expand Down
10 changes: 10 additions & 0 deletions postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @param {{ env: string; }} ctx
*/
module.exports = (ctx) => ({
plugins: {
tailwindcss: {},
autoprefixer: ctx.env === "production" ? {} : false,
cssnano: ctx.env === "production" ? {} : false,
},
});
49 changes: 45 additions & 4 deletions src/components/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,48 @@
import React, { ReactElement } from "react";
import React, { ReactElement, useMemo } from "react";
import { nanoid } from "nanoid";

function Input(): ReactElement {
return <input />;
interface Props {
value?: string | number;
onChange?(event: React.ChangeEvent<HTMLInputElement>): void;

label?: string;
placeholder?: string;

onFocus?(event: React.FocusEvent<HTMLInputElement>): void;
onMouseDown?(event: React.MouseEvent<HTMLInputElement, MouseEvent>): void;
onBlur?(event: React.FocusEvent<HTMLInputElement>): void;
}

function Input(
{ value, onChange, label, placeholder, onFocus, onMouseDown, onBlur }: Props,
ref: React.MutableRefObject<HTMLInputElement>
): ReactElement {
const componentId = useMemo(() => nanoid(), []);

return (
<div className="flex flex-col">
{label && (
<label
className="text-gray-600 font-bold mb-1 pr-4"
htmlFor={componentId}
>
{label}
</label>
)}
<input
ref={ref}
value={value}
onChange={onChange}
className="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-3 text-gray-700 focus:outline-none focus:bg-white focus:border-blue-500"
id={componentId}
type="text"
placeholder={placeholder}
onFocus={onFocus}
onMouseDown={onMouseDown}
onBlur={onBlur}
/>
</div>
);
}

export default Input;
export default React.forwardRef(Input);
3 changes: 3 additions & 0 deletions src/components/Suggester/Suggester.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.Suggester__options {
max-height: 176px;
}
167 changes: 167 additions & 0 deletions src/components/Suggester/Suggester.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import React, {
ReactElement,
useState,
useCallback,
useRef,
useMemo,
} from "react";
import Input from "../Input";
import { Option } from "../../types";
import styles from "./Suggester.module.css";
import clsx from "clsx";

interface Props {
options: Option[];

label?: string;
placeholder?: string;
async?: true;
loading?: boolean;
min?: number;
onSearch?(value: string): void;
onSelect?(option: Option): void;
}

function Suggester({
options,
label,
placeholder,
async,
loading,
min = 3,
onSearch,
onSelect,
}: Props): ReactElement {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState("");
const [open, setOpen] = useState(false);
const [shouldBlur, setShouldBlur] = useState(true);

const onChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = event.target.value;
setValue(inputValue);
setOpen(true);

if (async && onSearch && inputValue.length >= 3) {
onSearch(inputValue);
}
}, []);

const onFocus = useCallback(() => {
setOpen(true);
}, []);
const onBlur = useCallback(
(event: React.FocusEvent<HTMLInputElement>) => {
if (!shouldBlur) {
event.preventDefault();
event.target.focus();

setShouldBlur(true);
return;
}

setOpen(false);
},
[shouldBlur]
);

const onOptionMouseDown = useCallback(() => {
setShouldBlur(false);
}, []);
const onOptionMouseUp = useCallback(
(option: Option) => () => {
setOpen(false);
setShouldBlur(true);
setValue(option.label);

if (onSelect) {
onSelect(option);
}
},
[]
);

const filteredOptions = useMemo(() => {
const valueToMatch = value.toLowerCase();
return options.filter((option) => {
const label = option.label.toLowerCase();
return label.indexOf(valueToMatch) === 0;
});
}, [options, value]);

const getOptions = useCallback((): ReactElement | ReactElement[] => {
if (async && value.length < min) {
return (
<li
key={`Suggester__option-loading`}
className="select-none text-gray-600 py-1 px-3"
onMouseDown={onOptionMouseDown}
>
Enter at least {min} characters
</li>
);
}

if (async && loading) {
return (
<li
key={`Suggester__option-loading`}
className="select-none text-gray-600 py-1 px-3"
onMouseDown={onOptionMouseDown}
>
Loading...
</li>
);
}

if (filteredOptions.length) {
return filteredOptions.map((option, index) => (
<li
key={`Suggester__option-${index}`}
className="cursor-pointer select-none hover:bg-gray-300 active:bg-gray-400 text-gray-600 py-1 px-3"
onMouseDown={onOptionMouseDown}
onMouseUp={onOptionMouseUp(option)}
>
{option.label}
</li>
));
}

return (
<li
key={`Suggester__option-empty`}
className="select-none text-gray-600 py-1 px-3"
onMouseDown={onOptionMouseDown}
>
No suggestions
</li>
);
}, [loading, value, filteredOptions]);

return (
<div>
<Input
ref={inputRef}
value={value}
onChange={onChange}
label={label}
placeholder={placeholder}
onFocus={onFocus}
onMouseDown={onFocus}
onBlur={onBlur}
/>
{open && (
<ul
className={clsx(
"shadow-sm overflow-auto mt-1 bg-gray-200 w-full rounded py-2",
styles["Suggester__options"]
)}
>
{getOptions()}
</ul>
)}
</div>
);
}

export default Suggester;
1 change: 1 addition & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "*.module.css";
17 changes: 14 additions & 3 deletions src/stories/Input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@ import Input from "../components/Input";

export default {
title: "Input",
component: Input,
};

export function Default() {
return <Input />;
export function WithLabel() {
return (
<div className="p-4 max-w-md">
<Input label="Patient name" placeholder="John Doe" />
</div>
);
}

export function WithoutLabel() {
return (
<div className="p-4 max-w-md">
<Input placeholder="John Doe" />
</div>
);
}
Loading

0 comments on commit 7f9da73

Please sign in to comment.