Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MI-2752: Add MM auto suggestion search component #64

Merged
merged 4 commits into from
Mar 10, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions src/components/Input/Input.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
*/
const togglePlaceholderValue = (
event: React.ChangeEvent<HTMLInputElement>,
type: string
type: string,
) => {
if (!readOnly) {
event.target.placeholder = type === 'focus' ? '' : inputLabel;
Expand All @@ -79,7 +79,10 @@ export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
className={`mm-input ${className}`}
fullWidth={fullWidth}
>
{iconName && <Icon name={iconName} size={16} />}
{iconName && <Icon
name={iconName}
size={16}
/>}
<StyledInput
ref={ref}
placeholder={inputLabel}
Expand All @@ -93,10 +96,18 @@ export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
/>
{searchQuery && (
<StyledIconButton onClick={onClose}>
<Icon name="Close" size={12} iconColor="#ffffff" />
<Icon
name='Close'
size={12}
iconColor='#ffffff'
/>
</StyledIconButton>
)}
<DisplayFieldSet value={value} label={inputLabel} error={error} />
<DisplayFieldSet
value={value}
label={inputLabel}
error={error}
/>
</StyledInputContainer>
{Boolean(error) && <InputErrorMessage>{error}</InputErrorMessage>}
</div>
Expand Down
199 changes: 199 additions & 0 deletions src/components/MMSearch/MMSearch.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import React, { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react';

import { Input } from '@Components/Input';
import { List } from '@Components/List';
import { AutoCompleteWrapper } from '@Components/AutoComplete/AutoComplete.styles';

import { Constants } from '@Constants';

import { MMSearchProps } from './MMSearch';

/**
* MMSearch Component
*
* @example Correct usage
* ```ts
* <MMSearch
* searchValue=""
atisheyJain03 marked this conversation as resolved.
Show resolved Hide resolved
* setSearchValue={(val)=> {}}
* label='label'
* openOptions={true}
* item={ [
* {label: 'label 1', value: 'Value 1'},
* {label: 'label 2', value: 'Value 2'},
* {label: 'label 3', value: 'Value 3'},
* ]}
* />
* ```
*
* @example Correct usage for accessing the selected value
* ```ts
* <MMSearch
* searchValue=""
atisheyJain03 marked this conversation as resolved.
Show resolved Hide resolved
* setSearchValue={(val)=> {}}
* label='label'
* optionsLoading={true}
* fullWidth={true}
* item={[
* {label: 'label 1', value: 'Value 1', icon: 'User'},
* {label: 'label 2', value: 'Value 2', icon: 'User'},
* {label: 'label 3', value: 'Value 3', icon: 'User'},
* ]}
* onSelect={(event, options)=> {
* logic to use selected value;
* }}
* />
* ```
*/
export const MMSearch = (props: MMSearchProps) => {
const {
fullWidth,
items,
onSelect,
label,
className = '',
leadingIcon,
searchValue,
setSearchValue,
optionsLoading = false,
filterBy = '',
openOptions = false,
...restProps
} = props;

const [searchQuery, setSearchQuery] = useState<string>('');
const [open, setOpen] = useState<boolean>(openOptions);
const [active, setActive] = useState<number>(0);

const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(
null,
) as MutableRefObject<HTMLUListElement>;

/**
* On clicking anywhere other than `input field`, the dropdown closes
*/
const onDropDownCloseHandler = (e: MouseEvent) => {
if (e.target !== inputRef.current) {
setOpen(false);
}
};

// filter dropdown options based on the input value
atisheyJain03 marked this conversation as resolved.
Show resolved Hide resolved
const filteredOptions = useMemo(
() => (filterBy ? items.filter((item) => item.label?.startsWith(filterBy) && item.label !== filterBy) : items),
[filterBy, items]);

useEffect(() => {
document.body.addEventListener('click', onDropDownCloseHandler);

return () => {
document.body.removeEventListener('click', onDropDownCloseHandler);
};
}, []);

/**
* If 'isOpen' is true and 'value' is empty, then set the active index to 0 and scroll the list to (0,0)
*/
useEffect(() => {
if (open) {
setActive(0);
if (typeof listRef.current?.scrollTo === 'function') {
listRef.current.scrollTo(0, 0);
}
}
}, [open]);

/**
* The function is called when an event is detected on the keyboard,
* so you can browse through the list and select one.
*/
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (event) => {
if (!filteredOptions.length || !open) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
atisheyJain03 marked this conversation as resolved.
Show resolved Hide resolved
const option = items[active];

if (onSelect) {
onSelect(event, option);
}

setActive(0);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (active === 0) {
atisheyJain03 marked this conversation as resolved.
Show resolved Hide resolved
return;
}
setActive((prev) => prev - 1);
if (typeof listRef.current?.scrollBy === 'function') {
listRef.current.scrollBy(0, -Constants.ITEM_HEIGHT);
}
if (inputRef.current) {
inputRef.current.focus();
}
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
if (active === items.length - 1) {
return;
}
setActive((prev) => prev + 1);
if (typeof listRef.current.scrollBy === 'function') {
listRef.current.scrollBy(0, Constants.ITEM_HEIGHT);
}
}
};

return (
<AutoCompleteWrapper
fullWidth={fullWidth}
className={`mm-autocomplete ${className}`}
>
<Input
ref={inputRef}
fullWidth={fullWidth}
searchQuery={searchQuery}
onKeyDown={onKeyDown}
value={searchValue}
label={label}
iconName={leadingIcon}
onClose={() => {
setSearchValue('');
setSearchQuery('');
}}
onChange={(e) => {
setOpen(true);
setSearchQuery(e.target.value);
setSearchValue(e.target.value);
}}
{...restProps}
/>
{Boolean(filteredOptions.length) && (
<List
ref={listRef}
isOpen={open}
listItems={filteredOptions}
handleItemClick={(event, option) => {
setActive(0);
if (onSelect) {
onSelect(event, option);
}
setOpen(true);
if (inputRef.current) {
inputRef.current.focus();
}
}}
value={searchQuery}
loading={optionsLoading}
isAutocomplete={true}
activeItem={active}
/>
)}
</AutoCompleteWrapper>
);
};
92 changes: 92 additions & 0 deletions src/components/MMSearch/MMSearch.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { IconType } from '@Components/Icon';
import { ListItemType } from '@Components/List/List';

export interface MMSearchProps {

/**
* Label for the component
*/
label: string;

/**
* If `true`, the component is focused during the first mount
*
* @default false
*/
autoFocus?: boolean;

/**
* If `true`, the component will take up the full width of it's container.
*
* @default false
*/
fullWidth?: boolean;

/**
* A unique id given to the component
*/
id?: string;

/**
* The name applied to the component
*/
name?: string;

/**
* To override or extend the styles applied to the component
*/
className?: string;

/**
* Options to render in the dropdown of the autocomplete search list.
atisheyJain03 marked this conversation as resolved.
Show resolved Hide resolved
*/
items: ListItemType[];

/**
* Leading Icon for the component
*/
leadingIcon?: Exclude<IconType, 'Spinner'>;

/**
* The handler called when any dropdown item is selected
*
* @param event - Element in which the event have happened
* @param option - Option which the user have selected
*/
onSelect: (
event: React.MouseEvent<HTMLLIElement, MouseEvent> | KeyboardEvent<HTMLInputElement>,
option: ListItemType
) => void;

/**
* value of the search input component
atisheyJain03 marked this conversation as resolved.
Show resolved Hide resolved
*/
searchValue: string;

/**
* Function to set search value
* @param val - value of the input (event.target.value)
*/
setSearchValue: (val: string) => void;

/**
* loading flag to show whether options are loading
atisheyJain03 marked this conversation as resolved.
Show resolved Hide resolved
* @default false
*/
optionsLoading?: boolean;

/**
* Filter dropdown options based on this string. (It will exclude all options which doesn't starts with filterBy string)
*/
filterBy?: string;

/**
* Flag used to open options
*/
openOptions?: boolean;

/**
* Function used to handle onKeyPress functionality of input
*/
onKeyPress?: () => void;
}
51 changes: 51 additions & 0 deletions src/components/MMSearch/MMSearch.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { Story, Meta } from '@storybook/react';

import { MMSearchProps } from './MMSearch';

import { MMSearch } from './index';

export default {
title: 'Component/MMSearch',
component: MMSearch,
args: {
label: 'Search...',
searchValue: '',
setSearchValue: (val: string) => {
console.log(val);
},
items: [{ label: 'koko 1', value: 'Value 1' }],
},

atisheyJain03 marked this conversation as resolved.
Show resolved Hide resolved
} as Meta;

const MMSearchTemplate: Story<MMSearchProps> = (args) => <MMSearch {...args} />;

// Default
export const Default = MMSearchTemplate.bind({});

Default.args = {
label: 'Test',
optionsLoading: false,
filterBy: '',
openOptions: true,
};

export const FullWidth = MMSearchTemplate.bind({});
FullWidth.args = {
fullWidth: true,
items: [
{ label: 'koko 1', value: 'Value 1' },
{ label: 'asd 2', value: 'Value 2' },
{ label: 'xcs 3', value: 'Value 3' },
{ label: 'koko 4', value: 'Value 4' },
{ label: 'asd 5', value: 'Value 5' },
{ label: 'xcs 6', value: 'Value 6' },
{ label: 'koko 7', value: 'Value 7' },
{ label: 'asd 8', value: 'Value 8' },
{ label: 'xcs 9', value: 'Value 9' },
{ label: 'koko 10', value: 'Value 10' },
{ label: 'asd 11', value: 'Value 11' },
{ label: 'xcs 12', value: 'Value 12' },
],
};
1 change: 1 addition & 0 deletions src/components/MMSearch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MMSearch } from './MMSearch.component';
4 changes: 2 additions & 2 deletions src/components/Tabs/Tabs.component.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react';
import Tab from 'react-bootstrap/Tab';

import {StyledTabs} from './Tabs.styles';
import {TabsProps} from './Tabs';
import { StyledTabs } from './Tabs.styles';
import { TabsProps } from './Tabs';

export const Tabs = (props: TabsProps) => {
const {
Expand Down
Loading