@bitrise/bitkit 12.69.0 → 12.70.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bitrise/bitkit",
3
3
  "description": "Bitrise React component library",
4
- "version": "12.69.0",
4
+ "version": "12.70.0-alpha.1",
5
5
  "repository": "git@github.com:bitrise-io/bitkit.git",
6
6
  "main": "src/index.ts",
7
7
  "license": "UNLICENSED",
@@ -1,4 +1,4 @@
1
- const disabledStates = {
1
+ export const disabledStates = {
2
2
  primary: {
3
3
  _disabled: {
4
4
  bgGradient: 'linear(to-b, neutral.80, neutral.70)',
@@ -22,6 +22,7 @@ export interface DatePickerProps {
22
22
  selected?: DateRange;
23
23
  onApply?: (range: DateRange) => void;
24
24
  onClose: () => void;
25
+ onClear?: () => void;
25
26
  visible: boolean;
26
27
  }
27
28
 
@@ -30,7 +31,7 @@ export interface DatePickerProps {
30
31
  * range selection.
31
32
  */
32
33
  const DatePicker = (props: DatePickerProps) => {
33
- const { children, onApply, onClose, visible, selectable, selected } = props;
34
+ const { children, onApply, onClose, onClear, visible, selectable, selected } = props;
34
35
 
35
36
  const { isMobile } = useResponsive();
36
37
  const today = DateTime.now().startOf('day');
@@ -63,7 +64,7 @@ const DatePicker = (props: DatePickerProps) => {
63
64
  };
64
65
 
65
66
  const { leftViewDate, rightViewDate, updateLeftViewDate, updateRightViewDate } = useViewDate({
66
- initalView: dateFrom || selectable?.from,
67
+ initalView: dateFrom || selectable?.to,
67
68
  });
68
69
 
69
70
  const [preview, setPreview] = useState<'from' | 'to' | undefined>(undefined);
@@ -164,7 +165,7 @@ const DatePicker = (props: DatePickerProps) => {
164
165
  />
165
166
  )}
166
167
  </Box>
167
- <DatePickerFooter onApply={handleApply} onClose={handleClose} selected={selected} />
168
+ <DatePickerFooter onApply={handleApply} onClose={handleClose} onClear={onClear} selected={selected} />
168
169
  </>
169
170
  )}
170
171
  </Box>
@@ -8,10 +8,12 @@ const DatePickerFooter = ({
8
8
  selected,
9
9
  onClose,
10
10
  onApply,
11
+ onClear,
11
12
  }: {
12
13
  selected?: DateRange;
13
14
  onClose: () => void;
14
15
  onApply: () => void;
16
+ onClear?: () => void;
15
17
  }) => {
16
18
  return (
17
19
  <Box
@@ -20,6 +22,11 @@ const DatePickerFooter = ({
20
22
  gridTemplateRows={['1.25rem 2rem', 'unset']}
21
23
  gap="24"
22
24
  >
25
+ {!!onClear && (
26
+ <Button size="small" variant="tertiary" width="fit-content" onClick={() => onClear()}>
27
+ Clear
28
+ </Button>
29
+ )}
23
30
  <Text alignSelf="center" justifySelf="center" size="2" color="text.secondary" gridColumn={['1', '2']}>
24
31
  {selected?.from?.toFormat('DD', { locale: 'en-US' })}
25
32
  {selected?.from || selected?.to ? ' - ' : undefined}
@@ -0,0 +1,6 @@
1
+ import { createContext } from '@chakra-ui/react-utils';
2
+ import { FilterContextType } from './Filter.types';
3
+
4
+ export const [FilterContext, useFilterContext] = createContext<FilterContextType>({
5
+ name: 'FilterContext',
6
+ });
@@ -0,0 +1,71 @@
1
+ import { FilterContextType, FilterData, FilterOptions, FilterState } from './Filter.types';
2
+
3
+ export const FILTER_STORY_OPTIONS: FilterOptions =
4
+ 'Lorem ipsum dolor sit amet consectetur adipisicing elit Reiciendis reprehenderit laudantium laborum excepturi nam quae quod sunt expedita vel repellat cum cupiditate esse similique est ducimus provident eos numquam voluptas'.split(
5
+ ' ',
6
+ );
7
+
8
+ export const FILTER_STORY_DATA: FilterData = {
9
+ date_range: {
10
+ categoryName: 'Date',
11
+ categoryNamePlural: 'dates',
12
+ isPermanent: true,
13
+ },
14
+ pipeline: {
15
+ categoryName: 'Pipeline',
16
+ categoryNamePlural: 'Pipelines',
17
+ options: FILTER_STORY_OPTIONS,
18
+ },
19
+ stage: {
20
+ categoryName: 'Stage',
21
+ categoryNamePlural: 'Stages',
22
+ options: FILTER_STORY_OPTIONS,
23
+ dependsOn: ['pipeline'],
24
+ },
25
+ workflow: {
26
+ categoryName: 'Workflow',
27
+ isMultiple: true,
28
+ options: FILTER_STORY_OPTIONS,
29
+ },
30
+ branch: {
31
+ options: [
32
+ 'BIVS-2231-create-rename-to-utilisation-views',
33
+ 'revert-11937-revert-11884-RA-2060-release-manager-role',
34
+ 'master',
35
+ 'CI-2264-consolidate-other-provider-type-to-custom',
36
+ ],
37
+ },
38
+ app: {
39
+ categoryName: 'App',
40
+ isPermanent: true,
41
+ options: ['46b6b9a78a418ee8', '32b14416be4b7b24', '0a248b278e135ea7'],
42
+ optionsMap: {
43
+ '46b6b9a78a418ee8': 'bitrise-website',
44
+ '32b14416be4b7b24': 'bitkit',
45
+ '0a248b278e135ea7': 'pipeline-service',
46
+ },
47
+ },
48
+ test_case: {
49
+ categoryName: 'Test case',
50
+ onAsyncSearch: (category, q) => {
51
+ console.log('onAsyncSearch', { category, q });
52
+ return new Promise((resolve) => {
53
+ setTimeout(() => {
54
+ resolve(['found 1', 'found 2']);
55
+ }, 2000);
56
+ });
57
+ },
58
+ options: ['default 1', 'default 2', 'default 3'],
59
+ },
60
+ };
61
+
62
+ export const FILTER_STORY_INIT_STATE: FilterState = {
63
+ pipeline: ['ipsum'],
64
+ app: ['46b6b9a78a418ee8'],
65
+ };
66
+
67
+ export const FILTER_STORY_CONTEXT: FilterContextType = {
68
+ data: FILTER_STORY_DATA,
69
+ setPopoverOpen: () => {},
70
+ state: FILTER_STORY_INIT_STATE,
71
+ };
@@ -0,0 +1,161 @@
1
+ import { createMultiStyleConfigHelpers, SystemStyleObject } from '@chakra-ui/styled-system';
2
+ import { rem } from '../../utils/utils';
3
+ import { disabledStates } from '../Button/Button.theme';
4
+
5
+ export const parts = [
6
+ 'container',
7
+ 'icon',
8
+ 'content',
9
+ 'rightContent',
10
+ 'item',
11
+ 'tagEdit',
12
+ 'tagClear',
13
+ 'form',
14
+ 'formHeader',
15
+ 'formTitle',
16
+ 'formBadge',
17
+ 'formSearch',
18
+ 'formInputGroup',
19
+ 'formButtonGroup',
20
+ 'searchInput',
21
+ ] as const;
22
+ export type FilterStyle = Record<(typeof parts)[number], SystemStyleObject>;
23
+
24
+ const { defineMultiStyleConfig } = createMultiStyleConfigHelpers(parts);
25
+
26
+ const FilterTheme = defineMultiStyleConfig({
27
+ baseStyle: {
28
+ container: {
29
+ background: 'neutral.100',
30
+ display: 'flex',
31
+ flexDirection: ['column', 'row'],
32
+ gap: '12',
33
+ },
34
+ icon: {
35
+ margin: '4',
36
+ color: 'neutral.60',
37
+ },
38
+ content: {
39
+ display: 'flex',
40
+ gap: '12',
41
+ flexWrap: 'wrap',
42
+ },
43
+ rightContent: {
44
+ display: 'flex',
45
+ gap: '16',
46
+ marginInlineStart: 'auto',
47
+ },
48
+ item: {
49
+ border: '1px solid',
50
+ borderColor: 'neutral.80',
51
+ borderRadius: '4',
52
+ display: 'flex',
53
+ width: 'fit-content',
54
+ background: 'neutral.100',
55
+ wordBreak: 'break-word',
56
+ },
57
+ tagEdit: {
58
+ color: 'purple.10',
59
+ display: 'flex',
60
+ gap: '8',
61
+ alignItems: 'center',
62
+ paddingBlock: '4',
63
+ paddingInlineStart: rem(11),
64
+ paddingInlineEnd: rem(7),
65
+ borderRadius: '4',
66
+ _hover: {
67
+ background: 'neutral.93',
68
+ },
69
+ _active: {
70
+ background: 'neutral.90',
71
+ },
72
+ _disabled: {
73
+ cursor: 'not-allowed',
74
+ ...disabledStates.tertiary,
75
+ _hover: disabledStates.tertiary,
76
+ _active: disabledStates.tertiary,
77
+ },
78
+ },
79
+ tagClear: {
80
+ color: 'purple.10',
81
+ borderRadius: '4',
82
+ border: 0,
83
+ minWidth: rem(27),
84
+ height: rem(30),
85
+ justifyContent: 'flex-start',
86
+ svg: {
87
+ marginInlineStart: '4',
88
+ },
89
+ _hover: {
90
+ background: 'neutral.93',
91
+ },
92
+ _active: {
93
+ background: 'neutral.90',
94
+ },
95
+ _disabled: {
96
+ cursor: 'not-allowed',
97
+ ...disabledStates.tertiary,
98
+ _hover: disabledStates.tertiary,
99
+ _active: disabledStates.tertiary,
100
+ },
101
+ },
102
+ form: {
103
+ background: 'neutral.100',
104
+ borderRadius: '8',
105
+ padding: '16',
106
+ minWidth: rem(352),
107
+ maxWidth: rem(480),
108
+ },
109
+ formHeader: {
110
+ display: 'flex',
111
+ gap: '8',
112
+ justifyContent: 'space-between',
113
+ marginBlockEnd: '8',
114
+ },
115
+ formTitle: {
116
+ color: 'purpe.10',
117
+ fontWeight: 'demiBold',
118
+ },
119
+ formBadge: {
120
+ backgroundColor: 'neutral.93',
121
+ color: 'neutral.40',
122
+ fontWeight: 'bold',
123
+ fontVariantNumeric: 'tabular-nums',
124
+ },
125
+ formSearch: {
126
+ marginBlockEnd: '16',
127
+ },
128
+ formInputGroup: {
129
+ display: 'flex',
130
+ flexDirection: 'column',
131
+ gap: '12',
132
+ maxHeight: rem(196),
133
+ overflowY: 'scroll',
134
+ paddingInline: '12',
135
+ paddingBlock: rem(3),
136
+ },
137
+ formButtonGroup: {
138
+ display: 'flex',
139
+ justifyContent: 'flex-end',
140
+ marginBlockStart: '24',
141
+ },
142
+ searchInput: {
143
+ paddingLeft: '32',
144
+ paddingY: '4',
145
+ paddingRight: '8',
146
+ border: undefined,
147
+ borderRadius: '4',
148
+ borderColor: undefined,
149
+ boxShadow: undefined,
150
+ fontSize: '2',
151
+ _focusVisible: {
152
+ backgroundColor: 'neutral.95',
153
+ },
154
+ _hover: {
155
+ backgroundColor: 'neutral.95',
156
+ },
157
+ },
158
+ },
159
+ });
160
+
161
+ export default FilterTheme;
@@ -0,0 +1,159 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Modal, ModalOverlay, useMultiStyleConfig } from '@chakra-ui/react';
3
+ import Box, { BoxProps } from '../Box/Box';
4
+ import Button from '../Button/Button';
5
+ import Divider from '../Divider/Divider';
6
+ import Icon from '../Icon/Icon';
7
+ import { FilterContext } from './Filter.context';
8
+ import { FilterStyle } from './Filter.theme';
9
+ import { FilterContextType, FilterData, FilterState, FilterValue } from './Filter.types';
10
+ import { getDependents, hasAllDependencies } from './Filter.utils';
11
+ import FilterAdd from './FilterAdd/FilterAdd';
12
+ import FilterItem from './FilterItem/FilterItem';
13
+ import FilterSearch from './FilterSearch/FilterSearch';
14
+ import FilterDate from './FilterDate/FilterDate';
15
+
16
+ export interface FilterProps extends Omit<BoxProps, 'onChange'> {
17
+ filtersDependOn?: string[];
18
+ initialData: FilterData;
19
+ isLoading?: boolean;
20
+ onChange: (state: FilterState) => void;
21
+ showSearch?: boolean;
22
+ state: FilterState;
23
+ }
24
+
25
+ const Filter = (props: FilterProps) => {
26
+ const { filtersDependOn, initialData, isLoading, onChange, showSearch, state, ...rest } = props;
27
+
28
+ const isInited = useRef<boolean>(false);
29
+
30
+ const filterStyle = useMultiStyleConfig('Filter') as FilterStyle;
31
+
32
+ const [data] = useState<FilterData>(initialData);
33
+ const [isPopoverOpen, setPopoverOpen] = useState<boolean>(false);
34
+
35
+ const deleteFromState = (category: string, stateProp: FilterState): FilterState => {
36
+ const filteredState = { ...stateProp };
37
+ delete filteredState[category];
38
+
39
+ const dependents = getDependents(data, category, filtersDependOn);
40
+
41
+ dependents.forEach((dependent) => {
42
+ delete filteredState[dependent];
43
+ });
44
+
45
+ return filteredState;
46
+ };
47
+
48
+ const onFilterChange = (category: string, value: FilterValue) => {
49
+ if (isInited.current === false) {
50
+ return;
51
+ }
52
+ let newState = { ...state };
53
+ if (value && value.length > 0) {
54
+ newState[category] = value;
55
+ } else if (newState[category]) {
56
+ newState = deleteFromState(category, newState);
57
+ }
58
+ onChange(newState);
59
+ };
60
+
61
+ const onClear = (category: string) => {
62
+ onChange(deleteFromState(category, state));
63
+ };
64
+
65
+ const onClearFilters = () => {
66
+ onChange({});
67
+ };
68
+
69
+ const permanentCategories = Object.keys(data).filter(
70
+ (category) => data[category].isPermanent && category !== 'date_range',
71
+ );
72
+
73
+ const stateCategories = Object.keys(state).filter((c) => !['date_range', 'search'].includes(c));
74
+ const filteredStateCategories = stateCategories.filter((category) => !data[category].isPermanent);
75
+
76
+ const filterCategories = [...permanentCategories, ...filteredStateCategories];
77
+
78
+ const showClearFilters = stateCategories.length > 0 || (state.search && state.search.length > 0);
79
+
80
+ const contextValue: FilterContextType = useMemo(
81
+ () => ({
82
+ data: initialData,
83
+ filtersDependOn,
84
+ isLoading,
85
+ setPopoverOpen,
86
+ state,
87
+ }),
88
+ [filtersDependOn, isLoading, initialData, setPopoverOpen, state],
89
+ );
90
+
91
+ useEffect(() => {
92
+ if (isInited.current === false) {
93
+ isInited.current = true;
94
+ }
95
+ }, [isInited.current]);
96
+
97
+ return (
98
+ <FilterContext value={contextValue}>
99
+ <Box sx={filterStyle.container} {...rest}>
100
+ <Modal isOpen={isPopoverOpen} onClose={() => {}}>
101
+ {isPopoverOpen && <ModalOverlay />}
102
+ </Modal>
103
+ <Box sx={filterStyle.content}>
104
+ <Icon name="Filter" sx={filterStyle.icon} />
105
+ {!!data.date_range && <FilterDate onChange={onFilterChange} onClear={onClear} value={state.date_range} />}
106
+ {filterCategories.length > 0 &&
107
+ filterCategories.map((category) => {
108
+ const { categoryName, categoryNamePlural, dependsOn, isPermanent, optionsMap } = data[category];
109
+ if (hasAllDependencies(filterCategories, dependsOn)) {
110
+ return (
111
+ <FilterItem
112
+ category={category}
113
+ categoryName={categoryName}
114
+ categoryNamePlural={categoryNamePlural}
115
+ isPermanent={isPermanent}
116
+ key={category}
117
+ onChange={onFilterChange}
118
+ onClear={onClear}
119
+ optionsMap={optionsMap}
120
+ value={state[category]}
121
+ />
122
+ );
123
+ }
124
+ return null;
125
+ })}
126
+ <FilterAdd onChange={onFilterChange} />
127
+ </Box>
128
+ {(showClearFilters || showSearch) && (
129
+ <Box sx={filterStyle.rightContent}>
130
+ {showClearFilters && (
131
+ <Button
132
+ isDisabled={isLoading}
133
+ leftIconName="CloseSmall"
134
+ minWidth="7.5rem"
135
+ onClick={onClearFilters}
136
+ size="small"
137
+ variant="tertiary"
138
+ >
139
+ Clear filters
140
+ </Button>
141
+ )}
142
+ {showClearFilters && showSearch && (
143
+ <Divider orientation="vertical" size="1" variant="solid" flexShrink="0" />
144
+ )}
145
+ {showSearch && (
146
+ <FilterSearch
147
+ onChange={onFilterChange}
148
+ onClear={onClear}
149
+ value={(state.Search && state.Search[0]) || ''}
150
+ />
151
+ )}
152
+ </Box>
153
+ )}
154
+ </Box>
155
+ </FilterContext>
156
+ );
157
+ };
158
+
159
+ export default Filter;
@@ -0,0 +1,29 @@
1
+ import { Dispatch, SetStateAction } from 'react';
2
+
3
+ export type FilterOptions = string[];
4
+ export type FilterValue = string[];
5
+ export type FilterOptionsMap = Record<string, string>;
6
+
7
+ export type FilterSearchCallback = (category: string, q: string) => Promise<FilterValue>;
8
+
9
+ export type FilterCategoryProps = {
10
+ categoryName?: string;
11
+ categoryNamePlural?: string;
12
+ dependsOn?: string[];
13
+ isMultiple?: boolean;
14
+ isPermanent?: boolean;
15
+ onAsyncSearch?: FilterSearchCallback;
16
+ options?: FilterOptions;
17
+ optionsMap?: FilterOptionsMap;
18
+ };
19
+
20
+ export type FilterData = Record<string, FilterCategoryProps>;
21
+ export type FilterState = Record<string, FilterValue>;
22
+
23
+ export interface FilterContextType {
24
+ data: FilterData;
25
+ filtersDependOn?: string[];
26
+ isLoading?: boolean;
27
+ setPopoverOpen: Dispatch<SetStateAction<boolean>>;
28
+ state: FilterState;
29
+ }
@@ -0,0 +1,33 @@
1
+ import { FilterData, FilterOptionsMap } from './Filter.types';
2
+
3
+ export const hasAllDependencies = (stateKeys: string[], dependsOn?: string[]): boolean => {
4
+ if (!dependsOn || dependsOn.length === 0) {
5
+ return true;
6
+ }
7
+ return dependsOn.every((key) => stateKeys.includes(key));
8
+ };
9
+
10
+ export const getDependents = (data: FilterData, categoryKey: string, filtersDependOn?: string[]): string[] => {
11
+ const dependents: string[] = [];
12
+ if (filtersDependOn && filtersDependOn.includes(categoryKey)) {
13
+ Object.keys(data).forEach((category) => {
14
+ if (!data[category].isPermanent) {
15
+ dependents.push(category);
16
+ }
17
+ });
18
+ } else {
19
+ Object.keys(data).forEach((category) => {
20
+ if (data[category].dependsOn?.includes(categoryKey)) {
21
+ dependents.push(category);
22
+ }
23
+ });
24
+ }
25
+ return dependents;
26
+ };
27
+
28
+ export const getOptionLabel = (option: string, optionsMap?: FilterOptionsMap) => {
29
+ if (!optionsMap) {
30
+ return option;
31
+ }
32
+ return optionsMap[option] || option;
33
+ };
@@ -0,0 +1,92 @@
1
+ import { useState } from 'react';
2
+ import { Menu, MenuButton, MenuList, useDisclosure } from '@chakra-ui/react';
3
+ import Button from '../../Button/Button';
4
+ import MenuItem from '../../Menu/MenuItem';
5
+ import { useFilterContext } from '../Filter.context';
6
+ import FilterForm from '../FilterForm/FilterForm';
7
+ import { FilterValue } from '../Filter.types';
8
+ import { hasAllDependencies } from '../Filter.utils';
9
+
10
+ export interface FilterAddProps {
11
+ onChange: (category: string, selected: FilterValue) => void;
12
+ }
13
+
14
+ const FilterAdd = (props: FilterAddProps) => {
15
+ const { onChange } = props;
16
+ const [selectedCategory, setSelectedCategory] = useState<string | undefined>();
17
+
18
+ const { isOpen, onClose: closeMenu, onOpen: openMenu } = useDisclosure();
19
+
20
+ const { data, filtersDependOn, isLoading, setPopoverOpen, state } = useFilterContext();
21
+
22
+ const onCategorySelect = (category: string) => {
23
+ setSelectedCategory(category);
24
+ };
25
+
26
+ const onOpen = () => {
27
+ openMenu();
28
+ setPopoverOpen(true);
29
+ setSelectedCategory(undefined);
30
+ };
31
+
32
+ const onClose = () => {
33
+ setPopoverOpen(false);
34
+ closeMenu();
35
+ setSelectedCategory(undefined);
36
+ };
37
+
38
+ const onFilterChange = (category: string, value: FilterValue) => {
39
+ onClose();
40
+ onChange(category, value);
41
+ };
42
+
43
+ const stateKeys = Object.keys(state);
44
+
45
+ const isDisabled = !hasAllDependencies(stateKeys, filtersDependOn);
46
+
47
+ return (
48
+ <Menu closeOnSelect={false} isOpen={isOpen} onClose={onClose} onOpen={onOpen}>
49
+ <MenuButton
50
+ as={Button}
51
+ isDisabled={isDisabled}
52
+ isLoading={isLoading}
53
+ leftIconName="PlusOpen"
54
+ size="small"
55
+ variant="tertiary"
56
+ position={isOpen ? 'relative' : undefined}
57
+ zIndex={isOpen ? 'dialog' : undefined}
58
+ >
59
+ Add filter {isOpen}
60
+ </MenuButton>
61
+ <MenuList
62
+ paddingY={selectedCategory ? 0 : '12'}
63
+ position={isOpen ? 'relative' : undefined}
64
+ zIndex={isOpen ? 'dialog' : undefined}
65
+ >
66
+ {selectedCategory ? (
67
+ <FilterForm category={selectedCategory} onChange={onFilterChange} onCancel={onClose} />
68
+ ) : (
69
+ Object.keys(data).map((category) => {
70
+ const { categoryName, dependsOn, isPermanent } = data[category];
71
+ if (isPermanent) {
72
+ return null;
73
+ }
74
+ return (
75
+ <MenuItem
76
+ isDisabled={!hasAllDependencies(stateKeys, dependsOn)}
77
+ key={category}
78
+ onClick={() => onCategorySelect(category)}
79
+ pointerEvents="all"
80
+ rightIconName="ChevronRight"
81
+ >
82
+ {categoryName || category}
83
+ </MenuItem>
84
+ );
85
+ })
86
+ )}
87
+ </MenuList>
88
+ </Menu>
89
+ );
90
+ };
91
+
92
+ export default FilterAdd;
@@ -0,0 +1,78 @@
1
+ // import { useEffect } from 'react';
2
+ import { useEffect } from 'react';
3
+ import { useDisclosure, useMultiStyleConfig } from '@chakra-ui/react';
4
+ import { DateTime } from 'luxon';
5
+ import Box from '../../Box/Box';
6
+ import DatePicker, { DateRange, useDateRange } from '../../DatePicker/DatePicker';
7
+ import Icon from '../../Icon/Icon';
8
+ import Text from '../../Text/Text';
9
+ import Tooltip from '../../Tooltip/Tooltip';
10
+ import { useFilterContext } from '../Filter.context';
11
+ import { FilterStyle } from '../Filter.theme';
12
+ import { FilterValue } from '../Filter.types';
13
+
14
+ export type FilterDateProps = {
15
+ onChange: (category: string, value: FilterValue) => void;
16
+ onClear: (category: string) => void;
17
+ value: FilterValue;
18
+ };
19
+
20
+ const FilterDate = (props: FilterDateProps) => {
21
+ const { onChange, onClear, value } = props;
22
+ const filterStyle = useMultiStyleConfig('Filter') as FilterStyle;
23
+
24
+ const { isLoading, setPopoverOpen } = useFilterContext();
25
+
26
+ const { isOpen, onClose, onToggle } = useDisclosure();
27
+
28
+ const onDateRangeApply = (range: DateRange) => {
29
+ if (range.from && range.to) {
30
+ onChange('date_range', [String(range.from.toMillis()), String(range.to.toMillis())]);
31
+ }
32
+ };
33
+
34
+ const onClearClick = () => {
35
+ onClear('date_range');
36
+ onClose();
37
+ };
38
+
39
+ useEffect(() => {
40
+ setPopoverOpen(isOpen);
41
+ }, [isOpen]);
42
+
43
+ const now = DateTime.local();
44
+ const twoYearsAgo = now.minus({ years: 2 });
45
+
46
+ const selectable = useDateRange(twoYearsAgo, now);
47
+
48
+ const selectedRange: DateRange | undefined =
49
+ value && value[0]
50
+ ? new DateRange(DateTime.fromMillis(Number(value[0])), DateTime.fromMillis(Number(value[1])))
51
+ : undefined;
52
+
53
+ const label = selectedRange
54
+ ? `${selectedRange.from?.toFormat('LLL dd')} - ${selectedRange.to?.toFormat('LLL dd')}`
55
+ : 'All dates';
56
+
57
+ return (
58
+ <DatePicker
59
+ selectable={selectable}
60
+ selected={selectedRange}
61
+ onApply={onDateRangeApply}
62
+ onClose={onClose}
63
+ onClear={value?.length ? onClearClick : undefined}
64
+ visible={isOpen}
65
+ >
66
+ <Box sx={filterStyle.item} position={isOpen ? 'relative' : undefined} zIndex={isOpen ? 'dialog' : undefined}>
67
+ <Tooltip isDisabled={isLoading} label="Edit">
68
+ <Text as="button" disabled={isLoading} onClick={onToggle} size="2" sx={filterStyle.tagEdit}>
69
+ <Icon color="neutral.60" name="Calendar" size="16" /> {label}
70
+ <Icon name="ChevronDown" size="16" />
71
+ </Text>
72
+ </Tooltip>
73
+ </Box>
74
+ </DatePicker>
75
+ );
76
+ };
77
+
78
+ export default FilterDate;
@@ -0,0 +1,171 @@
1
+ import { FormEvent, useEffect, useMemo, useState } from 'react';
2
+ import { useMultiStyleConfig } from '@chakra-ui/react';
3
+ import Badge from '../../Badge/Badge';
4
+ import Box from '../../Box/Box';
5
+ import Button from '../../Button/Button';
6
+ import ButtonGroup from '../../ButtonGroup/ButtonGroup';
7
+ import Checkbox from '../../Form/Checkbox/Checkbox';
8
+ import CheckboxGroup from '../../Form/Checkbox/CheckboxGroup';
9
+ import Radio from '../../Form/Radio/Radio';
10
+ import RadioGroup from '../../Form/Radio/RadioGroup';
11
+ import SearchInput from '../../SearchInput/SearchInput';
12
+ import Text from '../../Text/Text';
13
+ import { FilterStyle } from '../Filter.theme';
14
+ import { FilterOptions, FilterValue } from '../Filter.types';
15
+ import { isEqual, useDebounce } from '../../../utils/utils';
16
+ import { getOptionLabel } from '../Filter.utils';
17
+ import { useFilterContext } from '../Filter.context';
18
+
19
+ export type FilterFormProps = {
20
+ category: string;
21
+ categoryName?: string;
22
+ onChange: (category: string, selected: FilterValue, previousValue: FilterValue) => void;
23
+ onCancel: () => void;
24
+ };
25
+
26
+ const FilterForm = (props: FilterFormProps) => {
27
+ const { category, categoryName, onChange, onCancel } = props;
28
+
29
+ const filterStyle = useMultiStyleConfig('Filter') as FilterStyle;
30
+
31
+ const { data, state } = useFilterContext();
32
+ const { isMultiple, onAsyncSearch, options, optionsMap } = data[category];
33
+ const value = state[category] || [];
34
+
35
+ const [selected, setSelected] = useState<FilterValue>(value);
36
+
37
+ const [searchValue, setSearchValue] = useState<string>('');
38
+ const debouncedSearchValue = useDebounce<string>(searchValue, 1000);
39
+
40
+ const [isLoading, setLoading] = useState(false);
41
+ const [foundOptions, setFoundOptions] = useState<FilterOptions>([]);
42
+
43
+ const isAsync = !!onAsyncSearch;
44
+ const withSearch = (options && options.length > 5) || isAsync;
45
+
46
+ const isDisabled = isEqual(selected, value);
47
+
48
+ const filteredOptions = useMemo(() => {
49
+ if (options?.length) {
50
+ return options.filter((o) => o.toLowerCase().includes(searchValue.toLowerCase()));
51
+ }
52
+ return [];
53
+ }, [searchValue]);
54
+
55
+ const onSearchChange = (q: string) => {
56
+ setSearchValue(q);
57
+ if (isAsync) {
58
+ if (q.length > 0) {
59
+ setLoading(true);
60
+ } else {
61
+ setFoundOptions([]);
62
+ }
63
+ }
64
+ };
65
+
66
+ const onSubmit = (e: FormEvent<HTMLDivElement>) => {
67
+ e.preventDefault();
68
+ const newSelected = selected[0] === '' ? [] : selected;
69
+ onChange(category, newSelected, value);
70
+ };
71
+
72
+ const onClearClick = () => {
73
+ setSelected([]);
74
+ onChange(category, [], value);
75
+ };
76
+
77
+ const onCancelClick = () => {
78
+ setSelected(value);
79
+ onCancel();
80
+ };
81
+
82
+ const getEmptyText = () => {
83
+ if (searchValue.length) {
84
+ return 'No result. Refine your search term.';
85
+ }
86
+ return '';
87
+ };
88
+
89
+ const getAsyncList = async () => {
90
+ if (onAsyncSearch) {
91
+ const response = await onAsyncSearch(category, searchValue);
92
+ setLoading(false);
93
+ setFoundOptions(response);
94
+ }
95
+ };
96
+
97
+ useEffect(() => {
98
+ if (debouncedSearchValue.length > 0) {
99
+ getAsyncList();
100
+ }
101
+ }, [debouncedSearchValue]);
102
+
103
+ const isEditMode = value.length !== 0;
104
+
105
+ const items =
106
+ isAsync && !!searchValue ? [...value, ...foundOptions] : Array.from(new Set([...value, ...filteredOptions]));
107
+
108
+ return (
109
+ <Box as="form" onSubmit={onSubmit} sx={filterStyle.form}>
110
+ <Box sx={filterStyle.formHeader}>
111
+ <Text as="h5" sx={filterStyle.formTitle}>
112
+ {categoryName || category}
113
+ </Text>
114
+ {isMultiple && <Badge sx={filterStyle.formBadge}>{selected[0] === '' ? '0' : selected.length}</Badge>}
115
+ </Box>
116
+ {(withSearch || isAsync) && (
117
+ <SearchInput
118
+ autoFocus
119
+ // isLoading={isLoading}
120
+ placeholder={isAsync ? 'Start typing to search options' : 'Start typing to find options'}
121
+ onChange={onSearchChange}
122
+ sx={filterStyle.formSearch}
123
+ value={searchValue}
124
+ />
125
+ )}
126
+ {isLoading && 'Loading...'}
127
+ {!isLoading && isMultiple && (
128
+ <CheckboxGroup onChange={setSelected} value={selected} sx={filterStyle.formInputGroup}>
129
+ {items.length
130
+ ? items.map((opt) => (
131
+ <Checkbox key={opt} value={opt}>
132
+ {getOptionLabel(opt, optionsMap)}
133
+ </Checkbox>
134
+ ))
135
+ : getEmptyText()}
136
+ </CheckboxGroup>
137
+ )}
138
+ {!isLoading && !isMultiple && (
139
+ <RadioGroup onChange={(v) => setSelected([v])} value={selected[0] || ''} sx={filterStyle.formInputGroup}>
140
+ <Radio value="">
141
+ <Text as="span" color="neutral.40" fontStyle="italic">
142
+ Not filtered
143
+ </Text>
144
+ </Radio>
145
+ {items.length
146
+ ? items.map((opt) => (
147
+ <Radio key={opt} value={opt}>
148
+ {getOptionLabel(opt, optionsMap)}
149
+ </Radio>
150
+ ))
151
+ : getEmptyText()}
152
+ </RadioGroup>
153
+ )}
154
+ <ButtonGroup spacing="12" sx={filterStyle.formButtonGroup}>
155
+ {isEditMode && (
156
+ <Button marginInlineEnd="auto" onClick={onClearClick} size="small" variant="tertiary">
157
+ Clear
158
+ </Button>
159
+ )}
160
+ <Button onClick={onCancelClick} size="small" type="reset" variant="secondary">
161
+ Cancel
162
+ </Button>
163
+ <Button isDisabled={isDisabled} size="small" type="submit">
164
+ {(selected.length === 0 || selected[0] === '') && isEditMode ? 'Remove' : 'Apply'}
165
+ </Button>
166
+ </ButtonGroup>
167
+ </Box>
168
+ );
169
+ };
170
+
171
+ export default FilterForm;
@@ -0,0 +1,100 @@
1
+ import { useEffect } from 'react';
2
+ import { useDisclosure, useMultiStyleConfig } from '@chakra-ui/react';
3
+ import Box from '../../Box/Box';
4
+ import Icon from '../../Icon/Icon';
5
+ import IconButton from '../../IconButton/IconButton';
6
+ import Popover from '../../Popover/Popover';
7
+ import PopoverContent from '../../Popover/PopoverContent';
8
+ import PopoverTrigger from '../../Popover/PopoverTrigger';
9
+ import Text from '../../Text/Text';
10
+ import Tooltip from '../../Tooltip/Tooltip';
11
+ import { FilterStyle } from '../Filter.theme';
12
+ import { FilterOptionsMap, FilterValue } from '../Filter.types';
13
+ import FilterForm from '../FilterForm/FilterForm';
14
+ import { useFilterContext } from '../Filter.context';
15
+ import { getOptionLabel } from '../Filter.utils';
16
+
17
+ export type FilterItemProps = {
18
+ category: string;
19
+ categoryName?: string;
20
+ categoryNamePlural?: string;
21
+ isPermanent?: boolean;
22
+ onChange: (category: string, value: FilterValue) => void;
23
+ onClear: (category: string) => void;
24
+ optionsMap?: FilterOptionsMap;
25
+ value: FilterValue;
26
+ };
27
+
28
+ const FilterItem = (props: FilterItemProps) => {
29
+ const { category, categoryName, categoryNamePlural, isPermanent, onChange, onClear, optionsMap, value } = props;
30
+
31
+ const pluralCategoryString = (categoryNamePlural || `${category}s`).toLowerCase();
32
+
33
+ const { isOpen, onToggle: togglePopover, onClose: closePopover } = useDisclosure();
34
+
35
+ const filterStyle = useMultiStyleConfig('Filter') as FilterStyle;
36
+
37
+ const { isLoading, setPopoverOpen } = useFilterContext();
38
+
39
+ const onToggle = () => {
40
+ togglePopover();
41
+ };
42
+
43
+ const onClose = () => {
44
+ closePopover();
45
+ setPopoverOpen(false);
46
+ };
47
+
48
+ const onFilterChange = (newCategory: string, newValue: FilterValue) => {
49
+ onClose();
50
+ onChange(newCategory, newValue);
51
+ };
52
+
53
+ const getText = () => {
54
+ if (!value || value.length === 0) {
55
+ return `All ${pluralCategoryString}`;
56
+ }
57
+ if (value.length > 1) {
58
+ return `${value.length} ${pluralCategoryString}`;
59
+ }
60
+ return `${categoryName || category}: ${getOptionLabel(value[0], optionsMap)}`;
61
+ };
62
+
63
+ useEffect(() => {
64
+ if (isOpen) {
65
+ setPopoverOpen(true);
66
+ }
67
+ }, [isOpen]);
68
+
69
+ return (
70
+ <Popover isLazy isOpen={isOpen} onClose={onClose}>
71
+ <PopoverTrigger>
72
+ <Box sx={filterStyle.item} position={isOpen ? 'relative' : undefined} zIndex={isOpen ? 'dialog' : undefined}>
73
+ <Tooltip isDisabled={isLoading} label="Edit">
74
+ <Text as="button" disabled={isLoading} onClick={onToggle} size="2" sx={filterStyle.tagEdit}>
75
+ {getText()}
76
+ {isPermanent && <Icon name="ChevronDown" size="16" />}
77
+ </Text>
78
+ </Tooltip>
79
+ {!isPermanent && (
80
+ <IconButton
81
+ aria-label={isLoading ? '' : 'Clear'}
82
+ iconName="CloseSmall"
83
+ isDisabled={isLoading}
84
+ onClick={() => onClear(category)}
85
+ size="small"
86
+ variant="tertiary"
87
+ sx={filterStyle.tagClear}
88
+ tooltipProps={{ shouldWrapChildren: false }}
89
+ />
90
+ )}
91
+ </Box>
92
+ </PopoverTrigger>
93
+ <PopoverContent>
94
+ <FilterForm category={category} categoryName={categoryName} onChange={onFilterChange} onCancel={onClose} />
95
+ </PopoverContent>
96
+ </Popover>
97
+ );
98
+ };
99
+
100
+ export default FilterItem;
@@ -0,0 +1,65 @@
1
+ import { ChangeEvent, useEffect, useState } from 'react';
2
+ import {
3
+ Input,
4
+ InputGroup,
5
+ InputLeftElement,
6
+ InputProps,
7
+ InputRightElement,
8
+ useMultiStyleConfig,
9
+ } from '@chakra-ui/react';
10
+ import { useDebounce } from '../../../utils/utils';
11
+ import Icon from '../../Icon/Icon';
12
+ import IconButton from '../../IconButton/IconButton';
13
+ import { FilterStyle } from '../Filter.theme';
14
+ import { FilterValue } from '../Filter.types';
15
+
16
+ export interface FilterSearchProps extends Omit<InputProps, 'onChange' | 'value'> {
17
+ onChange: (category: string, selected: FilterValue) => void;
18
+ onClear: (category: string) => void;
19
+ value: string;
20
+ }
21
+
22
+ const FilterSearch = (props: FilterSearchProps) => {
23
+ const { onChange, onClear, value, ...rest } = props;
24
+ const filterStyle = useMultiStyleConfig('Filter') as FilterStyle;
25
+
26
+ const [searchValue, setSearchValue] = useState(value);
27
+ const debouncedSearchValue = useDebounce<string>(searchValue, 500);
28
+
29
+ const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
30
+ setSearchValue(event.currentTarget.value);
31
+ };
32
+
33
+ const onClearClick = () => {
34
+ onClear('search');
35
+ };
36
+
37
+ useEffect(() => {
38
+ onChange('search', debouncedSearchValue.length ? [debouncedSearchValue] : []);
39
+ }, [debouncedSearchValue]);
40
+
41
+ useEffect(() => {
42
+ setSearchValue(value);
43
+ }, [value]);
44
+
45
+ const inputProps: InputProps = {
46
+ placeholder: 'Search...',
47
+ sx: filterStyle.searchInput,
48
+ onChange: onInputChange,
49
+ value: searchValue,
50
+ ...rest,
51
+ };
52
+ return (
53
+ <InputGroup maxWidth="9.25rem" height="fit-content">
54
+ <InputLeftElement margin="8">
55
+ <Icon color="neutral.60" name="Magnifier" size="16" />
56
+ </InputLeftElement>
57
+ <Input {...inputProps} />
58
+ <InputRightElement>
59
+ <IconButton aria-label="Clear" iconName="CloseSmall" onClick={onClearClick} size="small" variant="tertiary" />
60
+ </InputRightElement>
61
+ </InputGroup>
62
+ );
63
+ };
64
+
65
+ export default FilterSearch;
@@ -1,3 +1,5 @@
1
+ import { rem } from '../../../utils/utils';
2
+
1
3
  const FilterSwitch = {
2
4
  baseStyle: () => {
3
5
  return {
@@ -7,13 +9,20 @@ const FilterSwitch = {
7
9
  borderRadius: '4',
8
10
  border: '1px solid',
9
11
  borderColor: 'neutral.80',
10
- paddingBlock: '6',
11
- paddingInline: '12',
12
12
  cursor: 'pointer',
13
13
  position: 'relative',
14
14
  textOverflow: 'ellipsis',
15
15
  whiteSpace: 'nowrap',
16
16
  overflow: 'hidden',
17
+ paddingBlock: rem(5),
18
+ paddingInline: rem(11),
19
+ zIndex: 0,
20
+ fontSize: rem(14),
21
+ lineHeight: rem(20),
22
+ _focusVisible: {
23
+ boxShadow: 'outline',
24
+ zIndex: 1,
25
+ },
17
26
  _hover: {
18
27
  background: 'neutral.100',
19
28
  },
@@ -27,13 +36,6 @@ const FilterSwitch = {
27
36
  cursor: 'default',
28
37
  },
29
38
  },
30
- label: {
31
- margin: 0,
32
- userSelect: 'none',
33
- _focusVisible: {
34
- boxShadow: 'outline',
35
- },
36
- },
37
39
  };
38
40
  },
39
41
  };
@@ -36,7 +36,7 @@ const FilterSwitch = forwardRef<FilterSwitchProps, 'input'>((props, ref) => {
36
36
 
37
37
  const { name } = group;
38
38
 
39
- const { getInputProps, getLabelProps, getRootProps, getCheckboxProps } = useRadio({
39
+ const { getInputProps, getLabelProps, getCheckboxProps } = useRadio({
40
40
  ...rest,
41
41
  isChecked,
42
42
  isFocusable,
@@ -46,11 +46,9 @@ const FilterSwitch = forwardRef<FilterSwitchProps, 'input'>((props, ref) => {
46
46
  });
47
47
 
48
48
  return (
49
- <chakra.label {...getRootProps()} __css={styles.container}>
49
+ <chakra.label {...getLabelProps()} {...getCheckboxProps()} __css={styles.container}>
50
50
  <chakra.input {...getInputProps(htmlInputProps, ref)} />
51
- <chakra.span {...getCheckboxProps()} {...getLabelProps()} __css={styles.label}>
52
- {children}
53
- </chakra.span>
51
+ {children}
54
52
  </chakra.label>
55
53
  );
56
54
  });
@@ -1,7 +1,10 @@
1
1
  import { CheckboxGroup as ChakraCheckboxGroup, CheckboxGroupProps as ChakraCheckboxGroupProps } from '@chakra-ui/react';
2
2
  import Box, { BoxProps } from '../../Box/Box';
3
3
 
4
- export type CheckboxGroupProps = ChakraCheckboxGroupProps & Omit<BoxProps, 'onChange'>;
4
+ export type CheckboxGroupProps = Omit<ChakraCheckboxGroupProps, 'onChange'> &
5
+ Omit<BoxProps, 'onChange'> & {
6
+ onChange?(value: Array<string>): void;
7
+ };
5
8
 
6
9
  /**
7
10
  * CheckboxGroup component to help manage the checked state of its children Checkbox components and conveniently pass a few shared style props to each.
@@ -14,8 +14,8 @@ import Th from '../Table/Th';
14
14
  import Thead from '../Table/Thead';
15
15
  import Provider from '../Provider/Provider';
16
16
  import Input from '../Form/Input/Input';
17
- import FilterSwitchGroup from '../Form/FilterSwitch/FilterSwitchGroup';
18
- import FilterSwitch from '../Form/FilterSwitch/FilterSwitch';
17
+ import FilterSwitchGroup from '../Filter/FilterSwitch/FilterSwitchGroup';
18
+ import FilterSwitch from '../Filter/FilterSwitch/FilterSwitch';
19
19
  import * as bigIcons from './24x24';
20
20
  import { FigmaIcon, figmaIcons } from './figmaIcons';
21
21
 
@@ -1,46 +1,7 @@
1
1
  import { Text as ChakraText, TextProps as ChakraTextProps, forwardRef, ResponsiveValue } from '@chakra-ui/react';
2
2
  import { TextSizes } from '../../Foundations/Typography/Typography';
3
3
 
4
- type TextTags =
5
- | 'a'
6
- | 'abbr'
7
- | 'bdi'
8
- | 'bdo'
9
- | 'blockquote'
10
- | 'cite'
11
- | 'data'
12
- | 'dd'
13
- | 'dfn'
14
- | 'dt'
15
- | 'em'
16
- | 'figcaption'
17
- | 'h1'
18
- | 'h2'
19
- | 'h3'
20
- | 'h4'
21
- | 'h5'
22
- | 'h6'
23
- | 'kbd'
24
- | 'li'
25
- | 'label'
26
- | 'mark'
27
- | 'p'
28
- | 'pre'
29
- | 'q'
30
- | 'samp'
31
- | 'small'
32
- | 'span'
33
- | 'strong'
34
- | 'sub'
35
- | 'sup'
36
- | 'time'
37
- | 'var';
38
-
39
4
  export interface TextProps extends ChakraTextProps {
40
- /**
41
- * Any valid HTML text tag
42
- */
43
- as?: TextTags;
44
5
  /**
45
6
  * Font weight
46
7
  */
package/src/index.ts CHANGED
@@ -313,10 +313,10 @@ export { default as FileInput } from './Components/Form/FileInput/FileInput';
313
313
  export type { ToggletipProps as ToggleTooltipProps } from './Components/Toggletip/Toggletip';
314
314
  export { default as Toggletip } from './Components/Toggletip/Toggletip';
315
315
 
316
- export type { FilterSwitchProps } from './Components/Form/FilterSwitch/FilterSwitch';
317
- export { default as FilterSwitch } from './Components/Form/FilterSwitch/FilterSwitch';
316
+ export type { FilterSwitchProps } from './Components/Filter/FilterSwitch/FilterSwitch';
317
+ export { default as FilterSwitch } from './Components/Filter/FilterSwitch/FilterSwitch';
318
318
 
319
- export { default as FilterSwitchGroup } from './Components/Form/FilterSwitch/FilterSwitchGroup';
319
+ export { default as FilterSwitchGroup } from './Components/Filter/FilterSwitch/FilterSwitchGroup';
320
320
 
321
321
  export type { TablePaginationProps } from './Components/Table/TablePagination';
322
322
  export { default as TablePagination } from './Components/Table/TablePagination';
package/src/theme.ts CHANGED
@@ -43,6 +43,7 @@ import CodeSnippet from './Components/CodeSnippet/CodeSnippet.theme';
43
43
  import DefinitionTooltip from './Components/DefinitionTooltip/DefinitionTooltip.theme';
44
44
  import ExpandableCard from './Components/ExpandableCard/ExpandableCard.theme';
45
45
  import FileInput from './Components/Form/FileInput/FileInput.theme';
46
+ import Filter from './Components/Filter/Filter.theme';
46
47
 
47
48
  import breakpoints from './Foundations/Breakpoints/Breakpoints';
48
49
  import colors from './Foundations/Colors/Colors';
@@ -52,7 +53,7 @@ import sizes from './Foundations/Sizes/Sizes';
52
53
  import typography from './Foundations/Typography/Typography';
53
54
  import zIndices from './Foundations/Zindex/Zindex';
54
55
  import Toggletip from './Components/Toggletip/Toggletip.theme';
55
- import FilterSwitch from './Components/Form/FilterSwitch/FilterSwitch.theme';
56
+ import FilterSwitch from './Components/Filter/FilterSwitch/FilterSwitch.theme';
56
57
 
57
58
  const theme = {
58
59
  config: {
@@ -137,6 +138,7 @@ const theme = {
137
138
  Toggletip,
138
139
  ExpandableCard,
139
140
  FileInput,
141
+ Filter,
140
142
  },
141
143
  };
142
144
 
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'react';
1
+ import { useMemo, useEffect, useState } from 'react';
2
2
 
3
3
  export const rem = (input: number | string): number | string => {
4
4
  const value = typeof input === 'string' ? parseInt(input, 10) : input;
@@ -14,3 +14,25 @@ export function useObjectMemo<T extends object>(obj: T): T {
14
14
  return obj;
15
15
  }, Object.values(obj));
16
16
  }
17
+
18
+ export function isEqual(a: number | string | number[] | string[], b: number | string | number[] | string[]) {
19
+ if (Array.isArray(a) && Array.isArray(b)) {
20
+ return a.length === b.length && [...a].every((val) => [...b].includes(val));
21
+ }
22
+ return a === b;
23
+ }
24
+
25
+ // https://usehooks-ts.com/react-hook/use-debounce
26
+ export function useDebounce<T>(value: T, delay?: number): T {
27
+ const [debouncedValue, setDebouncedValue] = useState<T>(value);
28
+
29
+ useEffect(() => {
30
+ const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
31
+
32
+ return () => {
33
+ clearTimeout(timer);
34
+ };
35
+ }, [value, delay]);
36
+
37
+ return debouncedValue;
38
+ }