Skip to content

Commit

Permalink
feat: added select component
Browse files Browse the repository at this point in the history
  • Loading branch information
anuraghazra committed Aug 21, 2020
1 parent 1fe82cd commit 4f74414
Show file tree
Hide file tree
Showing 12 changed files with 596 additions and 1 deletion.
3 changes: 2 additions & 1 deletion .prettierrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false
"useTabs": false,
"endOfLine": "auto"
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"storybook": "start-storybook -p 6006"
},
"dependencies": {
"@types/lodash.debounce": "^4.0.6",
"lodash.debounce": "^4.0.8",
"reakit": "^1.2.2",
"reakit-system": "^0.14.2",
"reakit-utils": "^0.14.2"
Expand Down
64 changes: 64 additions & 0 deletions src/select/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from "react";
import { Meta } from "@storybook/react";

import {
SelectMenu,
SelectItem,
SelectTrigger,
SelectDropdown,
useSelectState,
} from "../select";

export default {
title: "Component/Select",
} as Meta;

const countries = [
{ name: "australia" },
{ name: "russia" },
{ name: "new zealand" },
{ name: "india" },
{ name: "california" },
{ name: "ireland" },
{ name: "indonesia" },
];

const Select: React.FC<{ state: any }> = ({ state }) => {
return (
<SelectMenu {...state} onChange={(value: any) => console.log(value)}>
<SelectTrigger {...state}>
<b style={{ color: state.isPlaceholder ? "gray" : "black" }}>
{state.isPlaceholder ? "Select one.." : state.selected.join(",")}
</b>
</SelectTrigger>

<SelectDropdown maxHeight={200} {...state}>
{countries.map(item => {
return (
<SelectItem {...state} key={item.name} value={item.name}>
{item.name}
</SelectItem>
);
})}
</SelectDropdown>
</SelectMenu>
);
};

export const Default: React.FC = () => {
const state = useSelectState({});

return <Select state={state} />;
};

export const MultiSelect: React.FC = () => {
const state = useSelectState({ allowMultiselect: true });

return <Select state={state} />;
};

export const DefaultSelected: React.FC = () => {
const state = useSelectState({ selected: "india" });

return <Select state={state} />;
};
153 changes: 153 additions & 0 deletions src/select/SelectDropdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { SELECT_KEYS } from "./__keys";
import debounce from "lodash.debounce";
import { BoxHTMLProps } from "reakit/ts/Box/Box";
import { useForkRef, contains } from "reakit-utils";
import { SelectStateReturn } from "./useSelectState";
import { createComponent, createHook } from "reakit-system";
import { useComposite, CompositeProps } from "reakit/Composite";

export type SelectDropdownOptions = CompositeProps &
Pick<
SelectStateReturn,
"isDropdownOpen" | "selected" | "setSelected" | "typehead" | "closeDropdown"
> & {
maxHeight?: string | number;
};

const useSelectDropdown = createHook<SelectDropdownOptions, BoxHTMLProps>({
name: "selectDropdown",
compose: useComposite,
keys: [
"maxHeight",
"move",
"isDropdownOpen",
"items",
"selected",
"setSelected",
"setCurrentId",
"typehead",
"closeDropdown",
...SELECT_KEYS,
],

useProps(
{
maxHeight = 500,
move,
isDropdownOpen,
items,
selected,
setSelected,
setCurrentId,
typehead,
closeDropdown,
},
{ ref: htmlRef, ...htmlProps },
) {
const ref = React.useRef<HTMLElement>(null);
const [position, setPosition] = React.useState("top");

const calculateDropdownPosition = React.useCallback(() => {
if (ref.current) {
const bounds = ref.current.getBoundingClientRect();
if (bounds.y < 0) {
setPosition("top");
}

if (bounds.bottom - 50 > window.innerHeight) {
setPosition("bottom");
}
}
}, []);

React.useLayoutEffect(() => {
calculateDropdownPosition();
}, [calculateDropdownPosition, isDropdownOpen]);

React.useLayoutEffect(() => {
window.addEventListener("scroll", calculateDropdownPosition);
return () =>
window.removeEventListener("scroll", calculateDropdownPosition);
}, [calculateDropdownPosition]);

React.useEffect(() => {
if (!items[0]) return;

const selectedItem = items.filter(item => {
if (!item.ref.current) return false;
return selected.includes(
item.ref.current.getAttribute("data-value") as string,
);
});

if (selectedItem.length > 0) {
setCurrentId(selectedItem[0].id);
move(selectedItem[0].id);
} else {
setCurrentId(items[0].id);
move(items[0].id);
}
}, [isDropdownOpen]);

React.useEffect(
debounce(() => {
if (typehead === "") return;

items.forEach(item => {
if (!item.ref.current) return;
const dataAttrValue = item.ref.current.getAttribute(
"data-value",
) as string;

if (
!selected.includes(dataAttrValue) &&
dataAttrValue.startsWith(typehead)
) {
setCurrentId(item.id);
// remain dropdown open on setSelected
setSelected(dataAttrValue, true);
move(item.id);
}
});
}, 400),
[typehead],
);

const clickOutside = React.useCallback(e => {
e.stopPropagation();
if (contains(e.target, ref.current as Element)) {
closeDropdown();
}
}, []);

React.useEffect(() => {
window.addEventListener("click", clickOutside);
return () => window.removeEventListener("click", clickOutside);
}, [clickOutside]);

return {
tabIndex: -1,
ref: useForkRef(ref, htmlRef),
role: "listbox",
"aria-orientation": "vertical",
"aria-hidden": !isDropdownOpen,
style: {
maxHeight: maxHeight,
overflowY: "scroll",
display: isDropdownOpen ? "block" : "none",
[position]: "100%",
},
...htmlProps,
};
},
});

const SelectDropdown = createComponent({
as: "div",
memo: true,
useHook: useSelectDropdown,
});

export { SelectDropdown };
41 changes: 41 additions & 0 deletions src/select/SelectItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { SELECT_KEYS } from "./__keys";
import {
useCompositeItem,
CompositeItemHTMLProps,
CompositeItemOptions,
} from "reakit/Composite";
import { createHook, createComponent } from "reakit-system";
import { SelectStateReturn } from "./useSelectState";

export type SelectItemOptions = CompositeItemOptions &
Pick<SelectStateReturn, "setSelected" | "selected"> & {
value: string;
};

export type SelectItemHTMLProp = CompositeItemHTMLProps;

const useSelectItem = createHook<SelectItemOptions, SelectItemHTMLProp>({
name: "selectItem",
compose: useCompositeItem,
keys: ["setSelected", "selected", "value", ...SELECT_KEYS],
useProps({ setSelected, selected, value }, { ref: htmlRef, ...htmlProps }) {
return {
role: "option",
"aria-label": value,
"aria-selected": selected.includes(value),
"data-value": value,
onClick: () => {
setSelected(value);
},
...htmlProps,
};
},
});

const SelectItem = createComponent({
as: "div",
memo: true,
useHook: useSelectItem,
});

export { SelectItem };
109 changes: 109 additions & 0 deletions src/select/SelectMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { createOnKeyDown } from "reakit-utils";
import { BoxHTMLProps } from "reakit/ts/Box/Box";
import { SelectStateReturn } from "./useSelectState";
import { createHook, createComponent } from "reakit-system";

export type SelectMenuOptions = Pick<
SelectStateReturn,
| "setTypehead"
| "isDropdownOpen"
| "selected"
| "openDropdown"
| "closeDropdown"
> & {
onChange?: (value: any) => void;
};

const useSelectMenu = createHook<SelectMenuOptions, BoxHTMLProps>({
name: "SelectMenu",
keys: [
"setTypehead",
"isDropdownOpen",
"selected",
"onChange",
"openDropdown",
"closeDropdown",
],

useProps(
{
setTypehead,
isDropdownOpen,
selected,
onChange,
openDropdown,
closeDropdown,
},
{ ...htmlProps },
) {
const keyClear = React.useRef<any>(null);
const [typed, setTyped] = React.useState("");

React.useEffect(() => {
onChange && onChange(selected);
}, [selected]);

const onKeyDown = React.useMemo(() => {
return createOnKeyDown({
stopPropagation: true,
keyMap: () => {
return {
Escape: () => {
closeDropdown();
},
ArrowUp: () => {
openDropdown();
},
ArrowDown: () => {
openDropdown();
},
};
},
});
}, []);

const clearKeyStrokes = () => {
setTypehead(typed);

if (keyClear.current) {
clearTimeout(keyClear.current);
keyClear.current = null;
}

keyClear.current = setTimeout(() => {
setTyped("");
keyClear.current = null;
}, 800);
};

React.useEffect(() => {
if (typed !== "") {
clearKeyStrokes();
}
}, [typed]);

return {
role: "button",
"aria-expanded": isDropdownOpen,
"aria-haspopup": "listbox",
onKeyDown,
onKeyPress: (e: React.KeyboardEvent) => {
e.persist();
// skip the enter key
if (e.which === 13) return;
setTyped(prev => prev + String.fromCharCode(e.which));
},
...htmlProps,
};
},
});

const SelectMenu = createComponent({
as: "div",
memo: true,
useHook: useSelectMenu,
});

export { SelectMenu };
Loading

0 comments on commit 4f74414

Please sign in to comment.