Skip to content

Commit

Permalink
feat: searchinput component
Browse files Browse the repository at this point in the history
  • Loading branch information
atanasster committed Jun 19, 2020
1 parent b68ff6c commit b69c9b4
Show file tree
Hide file tree
Showing 9 changed files with 339 additions and 1 deletion.
1 change: 1 addition & 0 deletions ui/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"react-switch": "^5.0.1",
"react-table": "^7.0.0",
"react-tabs": "^3.1.0",
"scroll-into-view-if-needed": "^2.2.25",
"theme-ui": "^0.3.1"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion ui/components/src/BlockContainer/BlockContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const BlockContainer: FC<BlockContainerProps> = ({
{title && collapsible && (
<Link
aria-label={isOpen ? 'Collapse this block' : 'Expand this block'}
css={{
sx={{
cursor: 'pointer',
}}
onClick={() => setIsOpen(!isOpen)}
Expand Down
6 changes: 6 additions & 0 deletions ui/components/src/Keyboard/Keyboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export const LEFT_ARROW = 37;
export const UP_ARROW = 38;
export const RIGHT_ARROW = 39;
export const DOWN_ARROW = 40;
export const BACKSPACE = 8;
export const TAB = 9;
export const RETURN = 13;
export const ESC = 27;
export const SPACE = 32;

/**
* Componet to monitor keystrokes. Can attach to child, document or window.
Expand All @@ -47,6 +52,7 @@ export const Keyboard: FC<KeyboardProps> = ({
const key = event.keyCode ? event.keyCode : event.which;
if (keys.includes(key)) {
event.preventDefault();
event.stopPropagation();
onKeyDown(key);
}
},
Expand Down
71 changes: 71 additions & 0 deletions ui/components/src/SearchInput/SearchInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useState, useEffect } from 'react';
import { faker } from '@component-controls/core';
import { SearchInput, SearchInputItem } from './SearchInput';

export default {
title: 'Components/SearchInput',
component: SearchInput,
};

interface FakeItem {
id: number;
label: string;
}
type FakeItems = FakeItem[];
let i = 10;
const useMockData = (): [FakeItems, (searchTerm: string) => void] => {
const [search, setSearch] = useState<string>('');
const [allItems] = useState(
Array.apply(null, Array(30)).map(() => ({
id: i++,
label: faker.name.findName(),
})),
);
const [items, setItems] = useState<FakeItems>([]);
useEffect(() => {
const searchTerm = search.toLowerCase();
setItems(
allItems.filter(item => item.label.toLowerCase().includes(searchTerm)),
);
}, [allItems, search]);
return [items, setSearch];
};

export const overview = () => {
const [items, setSearch] = useMockData();
return (
<SearchInput<FakeItem>
onSearch={search => setSearch(search)}
items={items}
onSelect={item => alert(JSON.stringify(item, null, 2))}
>
{props => (
<SearchInputItem {...props}>{props.item.label}</SearchInputItem>
)}
</SearchInput>
);
};

export const defaultRender = () => {
const [items, setSearch] = useMockData();
return (
<SearchInput
onSearch={search => setSearch(search)}
items={items}
onSelect={item => alert(JSON.stringify(item, null, 2))}
/>
);
};

export const placeholder = () => {
const [items, setSearch] = useMockData();
return (
<SearchInput
onSearch={search => setSearch(search)}
items={items}
onSelect={item => alert(JSON.stringify(item, null, 2))}
placeholder="start typing..."
aria-label="search items"
/>
);
};
222 changes: 222 additions & 0 deletions ui/components/src/SearchInput/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import React, { useState, ReactNode, useEffect } from 'react';
import scrollIntoView from 'scroll-into-view-if-needed';
import { Input, Box, InputProps, BoxProps } from 'theme-ui';
import { Popover, PopoverProps } from '../Popover';
import { Keyboard, DOWN_ARROW, UP_ARROW, RETURN, ESC } from '../Keyboard';

export interface SearchBoxCallbackProps<ItemType> {
/**
* curent to be rendered
*/
item: ItemType;
/**
* item index
*/
index: number;
/**
* unique key, to be used by react
*/
key: string;
/**
* whether the popover is open
*/
isOpen: boolean;
/**
* the search string
*/
search: string;
/**
* selected item index
*/
selected?: number;
/**
* select item function to be called when an item is selected
*/
selectItem: (item: ItemType, index: number, close: boolean) => void;
}

export interface SearchInputItemType {
id: number | string;
[key: string]: any;
}

/**
* display single search input item box
*/
export const SearchInputItem = <ItemType extends SearchInputItemType>({
children,
selected,
index,
item,
selectItem,
...rest
}: SearchBoxCallbackProps<ItemType> & BoxProps) => (
<Box
id={`search_item_${index}`}
variant="searchinput.item"
as="li"
className={selected === index ? 'active' : undefined}
onClick={e => {
e.preventDefault();
e.stopPropagation();
selectItem(item, index, true);
}}
{...rest}
>
{children}
</Box>
);

export interface SearchBoxProps<ItemType> {
/**
* callback on change of search input. user can retrieve items in this callback
*
*/
onSearch: (search: string) => Promise<void> | void;

/**
* on select a search item.
*/
onSelect?: (item: ItemType) => void;
/**
* children is a render prop to allow custom rendering of items, one at a time
*/
children?: (props: SearchBoxCallbackProps<ItemType>) => ReactNode;
/**
* items array
*/
items: ItemType[];
/**
* customize the popover
*/
popoverProps?: PopoverProps;
}

/**
* an input component combined with a popover, can be used for incremental search.
*/
export const SearchInput = <ItemType extends SearchInputItemType>({
onSearch,
items,
children,
onSelect,
popoverProps,
...rest
}: SearchBoxProps<ItemType> & Omit<InputProps, 'ref' | 'onSelect'>) => {
const [selected, setSelected] = useState<number | undefined>(undefined);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [search, setSearch] = useState<string>('');
const updateIsOpen = (newIsOpen: boolean) => {
setIsOpen(newIsOpen && items.length > 0);
};

useEffect(() => {
setIsOpen(items.length > 0 && search !== '');
}, [items, search]);

const updateSearch = async (newSearch: string) => {
await onSearch(newSearch);
setSearch(newSearch);
};

const selectItem = (item: ItemType, index: number, close: boolean) => {
setSelected(index);
if (isOpen && close) {
if (typeof onSelect === 'function') {
onSelect(item);
}
updateIsOpen(false);
} else {
updateIsOpen(true);
}
};
const onKeyPressed = (key: number) => {
switch (key) {
case DOWN_ARROW:
const downIndex = Math.min((selected || 0) + 1, items.length - 1);
if (downIndex >= 0) {
selectItem(items[downIndex], downIndex, false);
const itemEl = document.getElementById(`search_item_${downIndex}`);
if (itemEl) {
scrollIntoView(itemEl, { block: 'end', scrollMode: 'if-needed' });
}
}
break;
case UP_ARROW:
const upIndex = Math.max((selected || 0) - 1, 0);
if (upIndex < items.length) {
selectItem(items[upIndex], upIndex, false);
const itemEl = document.getElementById(`search_item_${upIndex}`);
if (itemEl) {
scrollIntoView(itemEl, { block: 'start', scrollMode: 'if-needed' });
}
}

break;
case RETURN:
if (typeof selected === 'number' && (selected || 0) < items.length) {
selectItem(items[selected], selected, true);
}
break;
case ESC:
updateIsOpen(false);
break;
}
};

return (
<Keyboard
keys={[DOWN_ARROW, UP_ARROW, RETURN, ESC]}
onKeyDown={onKeyPressed}
>
<Popover
trigger="none"
placement="bottom"
onVisibilityChange={(isVisible: boolean) => {
updateIsOpen(isVisible);
}}
tooltip={() => (
<Box variant="searchinput.popover">
{
<Box as="ul" variant="searchinput.list">
{items.map((item, index) => {
const props = {
item,
index,
isOpen,
search,
selected,
selectItem,
key: `search_item_${item.id || index}`,
};
return children ? (
children(props)
) : (
<SearchInputItem {...props}>
{item.label || item}
</SearchInputItem>
);
})}
</Box>
}
</Box>
)}
{...popoverProps}
tooltipShown={isOpen}
>
<Input
aria-label="type some text to start searching"
value={search}
onBlur={() => {
setTimeout(() => {
updateIsOpen(false);
}, 200);
}}
onClick={() => updateIsOpen(!isOpen)}
onChange={e => updateSearch(e.target.value)}
{...rest}
/>
</Popover>
</Keyboard>
);
};
1 change: 1 addition & 0 deletions ui/components/src/SearchInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SearchInput';
24 changes: 24 additions & 0 deletions ui/components/src/ThemeContext/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ export const theme: Theme = {
position: 'absolute',
left: -4,
px: 2,
pb: 2,
visibility: 'hidden',
':hover': {
visibility: 'visible',
Expand All @@ -265,6 +266,29 @@ export const theme: Theme = {
mb: 4,
},
},
searchinput: {
popover: {
minWidth: 300,
maxHeight: 400,
overflowY: 'auto',
},
list: {
listStyle: 'none',
pl: 1,
},
item: {
p: 2,
cursor: 'pointer',
':hover': {
backgroundColor: 'shadow',
},
'&.active': {
fontWeight: 'bold',
color: 'primary',
border: (t: Theme) => `2px solid ${t?.colors?.primary}`,
},
},
},
subtitle: {
color: 'fadedText',
fontWeight: 'body',
Expand Down
1 change: 1 addition & 0 deletions ui/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './Navmenu';
export * from './Pagination';
export * from './PanelContainer';
export * from './Popover';
export * from './SearchInput';
export * from './Sidebar';
export * from './SkipLinks';
export * from './Source';
Expand Down
Loading

0 comments on commit b69c9b4

Please sign in to comment.