Skip to content

Commit

Permalink
refactor: ♻️ ref based navigation (#346)
Browse files Browse the repository at this point in the history
* refactor: ♻️ ref based navigation

* refactor: ♻️ zero dependency

* refactor: ♻️ fixed search

* docs: 📝 updated documentation
  • Loading branch information
harshzalavadiya authored Mar 26, 2021
1 parent 80eae41 commit 0db6569
Show file tree
Hide file tree
Showing 21 changed files with 583 additions and 411 deletions.
5 changes: 3 additions & 2 deletions .storybook/main.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
const path = require("path");

module.exports = {
stories: ["../stories/**/*.stories.@(ts|tsx|js|jsx)"],
addons: [
"@storybook/addon-essentials",
"@storybook/addon-links",
"@storybook/addon-knobs",
"@storybook/addon-storysource",
"@storybook/addon-links",
],
// https://storybook.js.org/docs/react/configure/typescript#mainjs-configuration
typescript: {
Expand Down
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Simple and lightweight multiple selection dropdown component with `checkboxes`,

## ✨ Features

- 🕶 Zero Dependency
- 🍃 Lightweight (<5KB)
- 💅 Themeable
- ✌ Written w/ TypeScript
Expand All @@ -28,7 +29,7 @@ yarn add react-multi-select-component # yarn
import React, { useState } from "react";
import MultiSelect from "react-multi-select-component";

const Example: React.FC = () => {
const Example = () => {
const options = [
{ label: "Grapes 🍇", value: "grapes" },
{ label: "Mango 🥭", value: "mango" },
Expand All @@ -51,7 +52,7 @@ const Example: React.FC = () => {
options={options}
value={selected}
onChange={setSelected}
labelledBy={"Select"}
labelledBy="Select"
/>
</div>
);
Expand All @@ -67,7 +68,6 @@ export default Example;
| `labelledBy` | value for `aria-labelledby` | `string` | |
| `options` | options for dropdown | `[{label, value, disabled}]` | |
| `value` | pre-selected rows | `[{label, value}]` | `[]` |
| `focusSearchOnOpen` | focus on search input when opening | `boolean` | `true` |
| `hasSelectAll` | toggle 'Select All' option | `boolean` | `true` |
| `isLoading` | show spinner on select | `boolean` | `false` |
| `shouldToggleOnHover` | toggle dropdown on hover option | `boolean` | `false` |
Expand All @@ -80,10 +80,10 @@ export default Example;
| `className` | class name for parent component | `string` | `multi-select` |
| `valueRenderer` | custom dropdown header [docs](#-custom-value-renderer) | `function` | |
| `ItemRenderer` | custom dropdown option [docs](#-custom-item-renderer) | `function` | |
| `ClearIcon` | Custom Clear Icon for Search | `JSX.element` | |
| `ArrowRenderer` | Custom Arrow Icon for Dropdown | `JSX.element` | |
| `ClearIcon` | Custom Clear Icon for Search | `ReactNode` | |
| `ArrowRenderer` | Custom Arrow Icon for Dropdown | `ReactNode` | |
| `debounceDuration` | debounce duraion for Search | `number` | `300` |
| `ClearSelectedIcon` | Custom Clear Icon for Selected Items | `JSX.element` | `function` |
| `ClearSelectedIcon` | Custom Clear Icon for Selected Items | `ReactNode` | |

## 🔍 Custom filter logic

Expand Down Expand Up @@ -159,7 +159,6 @@ You can override CSS variables to customize the appearance
- This project gets inspiration and several pieces of logical code from [react-multiple-select](https://github.com/Khan/react-multi-select/)
- [TypeScript](https://github.com/microsoft/typescript)
- [TSDX](https://github.com/jaredpalmer/tsdx)
- [Goober](https://github.com/cristianbote/goober)

## 📜 License

Expand Down
35 changes: 17 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"main": "dist/index.js",
"module": "dist/react-multi-select-component.esm.js",
"typings": "dist/index.d.ts",
"sideEffects": false,
"scripts": {
"start": "tsdx watch",
"build": "tsdx build",
Expand All @@ -24,31 +23,31 @@
"peerDependencies": {
"react": ">=17"
},
"dependencies": {
"goober": "^2.0.30"
},
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.12.13",
"@size-limit/preset-small-lib": "^4.9.2",
"@storybook/addon-essentials": "^6.1.17",
"@babel/core": "^7.13.10",
"@size-limit/preset-small-lib": "^4.10.1",
"@storybook/addon-essentials": "^6.1.21",
"@storybook/addon-info": "^5.3.21",
"@storybook/addon-knobs": "^6.1.17",
"@storybook/addon-links": "^6.1.17",
"@storybook/addon-storysource": "^6.1.17",
"@storybook/addons": "^6.1.17",
"@storybook/react": "^6.1.17",
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"@storybook/addon-knobs": "^6.1.21",
"@storybook/addon-links": "^6.1.21",
"@storybook/addons": "^6.1.21",
"@storybook/react": "^6.1.21",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.3",
"babel-loader": "^8.2.2",
"eslint-plugin-prettier": "^3.3.1",
"husky": "^4.3.8",
"postcss": "^8.2.8",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-is": "^17.0.1",
"size-limit": "^4.9.2",
"react-dom": "^17.0.2",
"react-is": "^17.0.2",
"rollup-plugin-postcss": "^4.0.0",
"size-limit": "^4.10.1",
"style-loader": "^2.0.0",
"tsdx": "^0.14.1",
"tslib": "^2.1.0",
"typescript": "^4.1.5"
"typescript": "^4.2.3"
},
"browserslist": [
"defaults",
Expand Down
79 changes: 79 additions & 0 deletions src/hooks/use-key.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* copied from https://github.com/imbhargav5/rooks/blob/master/packages/shared/useKeyRef.ts
*/

import { Ref, useEffect, useCallback, useRef, useMemo } from "react";

interface Options {
/**
* Condition which if true, will enable the event listeners
*/
when?: boolean;
/**
* Keyboardevent types to listen for. Valid options are keyDown, keyPress and keyUp
*/
eventTypes?: Array<string | number>;
/**
* target ref on which the events should be listened. If no target is specified,
* events are listened to on the window
*/
target?: Ref<HTMLElement> | null;
}

const defaultOptions = {
when: true,
eventTypes: ["keydown"],
};

/**
* useKey hook
*
* Fires a callback on keyboard events like keyDown, keyPress and keyUp
*
* @param {[string|number]} keyList
* @param {function} callback
* @param {Options} options
*/
function useKey(
input: string | number | Array<string | number>,
callback: (e: KeyboardEvent) => any,
opts?: Options
): void {
const keyList: Array<string | number> = useMemo(
() => (Array.isArray(input) ? input : [input]),
[input]
);
const options = Object.assign({}, defaultOptions, opts);
const { when, eventTypes } = options;
const callbackRef = useRef<(e: KeyboardEvent) => any>(callback);
let { target } = options;

useEffect(() => {
callbackRef.current = callback;
});

const handle = useCallback(
(e: KeyboardEvent) => {
if (keyList.some((k) => e.key === k || e.code === k)) {
callbackRef.current(e);
}
},
[keyList]
);

useEffect((): any => {
if (when && typeof window !== "undefined") {
const targetNode = target ? target["current"] : window;
eventTypes.forEach((eventType) => {
targetNode && targetNode.addEventListener(eventType, handle);
});
return () => {
eventTypes.forEach((eventType) => {
targetNode && targetNode.removeEventListener(eventType, handle);
});
};
}
}, [when, eventTypes, keyList, target, callback]);
}

export { useKey };
1 change: 0 additions & 1 deletion src/hooks/use-multi-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const defaultStrings = {

const defaultProps: Partial<ISelectProps> = {
value: [],
focusSearchOnOpen: true,
hasSelectAll: true,
className: "multi-select",
debounceDuration: 200,
Expand Down
6 changes: 0 additions & 6 deletions src/lib/classnames.ts

This file was deleted.

7 changes: 7 additions & 0 deletions src/lib/constants.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const KEY = {
ARROW_DOWN: "ArrowDown",
ARROW_UP: "ArrowUp",
ENTER: "Enter",
ESCAPE: "Escape",
SPACE: "Space",
};
1 change: 0 additions & 1 deletion src/lib/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export interface Option {
export interface ISelectProps {
options: Option[];
value: Option[];
focusSearchOnOpen?: boolean;
onChange?;
valueRenderer?: (selected: Option[], options: Option[]) => ReactNode;
ItemRenderer?: Function;
Expand Down
90 changes: 15 additions & 75 deletions src/multi-select/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,70 +3,18 @@
* and hosts it in the component. When the component is selected, it
* drops-down the contentComponent and applies the contentProps.
*/
import { css } from "goober";
import React, { useEffect, useRef, useState } from "react";

import { useDidUpdateEffect } from "../hooks/use-did-update-effect";
import { useKey } from "../hooks/use-key";
import { useMultiSelect } from "../hooks/use-multi-select";
import { cn } from "../lib/classnames";
import { KEY } from "../lib/constants";
import SelectPanel from "../select-panel";
import { Cross } from "../select-panel/cross";
import { Arrow } from "./arrow";
import { DropdownHeader } from "./header";
import { Loading } from "./loading";

const PanelContainer = css({
position: "absolute",
zIndex: 1,
top: "100%",
width: "100%",
paddingTop: "8px",
".panel-content": {
maxHeight: "300px",
overflowY: "auto",
borderRadius: "var(--rmsc-radius)",
background: "var(--rmsc-bg)",
boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.1), 0 4px 11px rgba(0, 0, 0, 0.1)",
},
});

const DropdownContainer = css({
position: "relative",
outline: 0,
backgroundColor: "var(--rmsc-bg)",
border: "1px solid var(--rmsc-border)",
borderRadius: "var(--rmsc-radius)",
"&:focus-within": {
boxShadow: "var(--rmsc-main) 0 0 0 1px",
borderColor: "var(--rmsc-main)",
},
});

const DropdownHeading = css({
position: "relative",
padding: "0 var(--rmsc-p)",
display: "flex",
alignItems: "center",
width: "100%",
height: "var(--rmsc-h)",
cursor: "default",
outline: 0,
".dropdown-heading-value": {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
},
});

const ClearSelectedButton = css({
cursor: "pointer",
background: "none",
border: 0,
padding: 0,
display: "flex",
});

const Dropdown = () => {
const {
t,
Expand Down Expand Up @@ -103,24 +51,20 @@ const Dropdown = () => {

const handleKeyDown = (e) => {
if (isInternalExpand) {
switch (e.which) {
case 27: // Escape
case 38: // Up Arrow
setExpanded(false);
wrapper?.current?.focus();
break;
case 32: // Space
case 13: // Enter Key
case 40: // Down Arrow
setExpanded(true);
break;
default:
return;
if (e.code === KEY.ESCAPE) {
setExpanded(false);
wrapper?.current?.focus();
} else {
setExpanded(true);
}
}
e.preventDefault();
};

useKey([KEY.ENTER, KEY.ARROW_DOWN, KEY.SPACE, KEY.ESCAPE], handleKeyDown, {
target: wrapper,
});

const handleHover = (iexpanded: boolean) => {
isInternalExpand && shouldToggleOnHover && setExpanded(iexpanded);
};
Expand Down Expand Up @@ -151,30 +95,26 @@ const Dropdown = () => {
return (
<div
tabIndex={0}
className={cn(DropdownContainer, "dropdown-container")}
className="dropdown-container"
aria-labelledby={labelledBy}
aria-expanded={expanded}
aria-readonly={true}
aria-disabled={disabled}
ref={wrapper}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className={cn(DropdownHeading, "dropdown-heading")}
onClick={toggleExpanded}
>
<div className="dropdown-heading" onClick={toggleExpanded}>
<div className="dropdown-heading-value">
<DropdownHeader />
</div>
{isLoading && <Loading />}
{value.length > 0 && (
<button
type="button"
className={cn(ClearSelectedButton, "clear-selected-button")}
className="clear-selected-button"
onClick={handleClearSelected}
disabled={disabled}
aria-label={t("clearSelected")}
Expand All @@ -185,7 +125,7 @@ const Dropdown = () => {
<FinalArrow expanded={expanded} />
</div>
{expanded && (
<div className={cn(PanelContainer, "dropdown-content")}>
<div className="dropdown-content">
<div className="panel-content">
<SelectPanel />
</div>
Expand Down
Loading

0 comments on commit 0db6569

Please sign in to comment.