@snack-uikit/list 0.3.5 → 0.4.1-preview-2484f987.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +28 -10
  3. package/dist/components/Items/AccordionItem/AccordionItem.js +3 -6
  4. package/dist/components/Items/BaseItem/BaseItem.js +1 -1
  5. package/dist/components/Items/BaseItem/styles.module.css +7 -11
  6. package/dist/components/Items/hooks.js +10 -10
  7. package/dist/components/Items/styles.module.css +5 -5
  8. package/dist/components/Items/types.d.ts +1 -0
  9. package/dist/components/Items/utils.js +5 -4
  10. package/dist/components/Lists/Droplist/DropList.js +8 -11
  11. package/dist/components/Lists/List/List.d.ts +3 -5
  12. package/dist/components/Lists/List/List.js +1 -6
  13. package/dist/components/Lists/ListPrivate/ListPrivate.d.ts +3 -5
  14. package/dist/components/Lists/ListPrivate/ListPrivate.js +10 -10
  15. package/dist/components/Lists/contexts/SelectionProvider.d.ts +3 -3
  16. package/dist/components/Lists/contexts/SelectionProvider.js +14 -11
  17. package/dist/components/Lists/index.d.ts +1 -1
  18. package/dist/components/Lists/styles.module.css +0 -14
  19. package/dist/components/Lists/types.d.ts +23 -10
  20. package/dist/components/index.d.ts +1 -0
  21. package/dist/components/index.js +1 -0
  22. package/dist/helperComponents/ListEmptyState/ListEmptyState.d.ts +14 -0
  23. package/dist/helperComponents/ListEmptyState/ListEmptyState.js +9 -0
  24. package/dist/helperComponents/ListEmptyState/hooks.d.ts +10 -0
  25. package/dist/helperComponents/ListEmptyState/hooks.js +16 -0
  26. package/dist/helperComponents/ListEmptyState/index.d.ts +2 -0
  27. package/dist/helperComponents/ListEmptyState/index.js +2 -0
  28. package/dist/helperComponents/ListEmptyState/styles.module.css +8 -0
  29. package/dist/helperComponents/index.d.ts +1 -0
  30. package/dist/helperComponents/index.js +1 -0
  31. package/dist/hooks.d.ts +2 -0
  32. package/dist/hooks.js +29 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.js +2 -0
  35. package/dist/utils.d.ts +3 -0
  36. package/dist/utils.js +17 -0
  37. package/package.json +12 -3
  38. package/src/components/Items/AccordionItem/AccordionItem.tsx +4 -7
  39. package/src/components/Items/BaseItem/BaseItem.tsx +1 -0
  40. package/src/components/Items/BaseItem/styles.module.scss +3 -6
  41. package/src/components/Items/hooks.tsx +13 -10
  42. package/src/components/Items/styles.module.scss +2 -2
  43. package/src/components/Items/types.ts +2 -0
  44. package/src/components/Items/utils.ts +5 -3
  45. package/src/components/Lists/Droplist/DropList.tsx +13 -12
  46. package/src/components/Lists/List/List.tsx +1 -5
  47. package/src/components/Lists/ListPrivate/ListPrivate.tsx +50 -48
  48. package/src/components/Lists/contexts/SelectionProvider.tsx +11 -8
  49. package/src/components/Lists/index.ts +3 -0
  50. package/src/components/Lists/styles.module.scss +0 -12
  51. package/src/components/Lists/types.ts +27 -10
  52. package/src/components/index.ts +2 -0
  53. package/src/helperComponents/ListEmptyState/ListEmptyState.tsx +35 -0
  54. package/src/helperComponents/ListEmptyState/hooks.ts +42 -0
  55. package/src/helperComponents/ListEmptyState/index.ts +2 -0
  56. package/src/helperComponents/ListEmptyState/styles.module.scss +11 -0
  57. package/src/helperComponents/index.ts +1 -0
  58. package/src/hooks.ts +45 -0
  59. package/src/index.ts +3 -0
  60. package/src/utils.ts +25 -0
@@ -3,9 +3,9 @@ import { ForwardedRef, forwardRef, RefObject, useMemo } from 'react';
3
3
 
4
4
  import { Spinner } from '@snack-uikit/loaders';
5
5
  import { Scroll } from '@snack-uikit/scroll';
6
- import { ToggleGroup } from '@snack-uikit/toggles';
7
6
  import { extractSupportProps } from '@snack-uikit/utils';
8
7
 
8
+ import { ListEmptyState, useEmptyState } from '../../../helperComponents';
9
9
  import { PinBottomGroupItem, PinTopGroupItem, SearchItem, useRenderItems } from '../../Items';
10
10
  import { ListContextProvider } from '../contexts';
11
11
  import commonStyles from '../styles.module.scss';
@@ -30,13 +30,16 @@ export const ListPrivate = forwardRef<HTMLElement, ListPrivateProps>(
30
30
  scrollContainerRef,
31
31
  footer,
32
32
  loading,
33
- noData = 'No data',
34
- noResults = 'No results',
35
33
  size = 's',
36
- marker,
34
+ marker = true,
37
35
  limitedScrollHeight,
38
36
  className,
39
37
  parent = 'list',
38
+ noDataState,
39
+ noResultsState,
40
+ errorDataState,
41
+ dataError,
42
+ dataFiltered,
40
43
  ...props
41
44
  },
42
45
  ref,
@@ -44,7 +47,10 @@ export const ListPrivate = forwardRef<HTMLElement, ListPrivateProps>(
44
47
  const itemsJSX = useRenderItems(items);
45
48
  const itemsPinTopJSX = useRenderItems(pinTop ?? []);
46
49
  const itemsPinBottomJSX = useRenderItems(pinBottom ?? []);
47
- const hasNoItems = items.length + (pinTop?.length ?? 0) + (pinBottom?.length ?? 0) === 0;
50
+
51
+ const emptyStates = useEmptyState({ noDataState, noResultsState, errorDataState });
52
+
53
+ const hasNoItems = items.length === 0;
48
54
 
49
55
  const loadingJSX = useMemo(
50
56
  () =>
@@ -68,19 +74,17 @@ export const ListPrivate = forwardRef<HTMLElement, ListPrivateProps>(
68
74
  <div className={styles.content}>
69
75
  {itemsJSX}
70
76
  {loadingJSX}
71
- {hasNoItems && !search?.value && !loading && (
72
- <div className={commonStyles.infoBlock} data-test-id='list__no-data'>
73
- {noData}
74
- </div>
75
- )}
76
- {hasNoItems && search?.value && !loading && (
77
- <div className={commonStyles.infoBlock} data-test-id='list__no-results'>
78
- {noResults}
79
- </div>
80
- )}
77
+
78
+ <ListEmptyState
79
+ loading={loading}
80
+ dataError={dataError}
81
+ emptyStates={emptyStates}
82
+ itemsLength={items.length}
83
+ dataFiltered={dataFiltered ?? Boolean(search?.value)}
84
+ />
81
85
  </div>
82
86
  ),
83
- [hasNoItems, itemsJSX, loading, loadingJSX, noData, noResults, search?.value],
87
+ [dataError, dataFiltered, emptyStates, items.length, itemsJSX, loading, loadingJSX, search?.value],
84
88
  );
85
89
 
86
90
  const listJSX = (
@@ -95,38 +99,36 @@ export const ListPrivate = forwardRef<HTMLElement, ListPrivateProps>(
95
99
  role='menu'
96
100
  {...extractSupportProps(props)}
97
101
  >
98
- <ToggleGroup selectionMode={'multiple'}>
99
- {(Number(pinTop?.length) > 0 || search) && (
100
- <PinTopGroupItem>
101
- {search && <SearchItem search={search} />}
102
-
103
- {Number(pinTop?.length) > 0 && itemsPinTopJSX}
104
- </PinTopGroupItem>
105
- )}
106
-
107
- {scroll ? (
108
- <Scroll
109
- className={cn({
110
- [commonStyles.scrollContainerS]: scroll && limitedScrollHeight && size === 's',
111
- [commonStyles.scrollContainerM]: scroll && limitedScrollHeight && size === 'm',
112
- [commonStyles.scrollContainerL]: scroll && limitedScrollHeight && size === 'l',
113
- })}
114
- barHideStrategy='leave'
115
- size={size === 's' ? 's' : 'm'}
116
- ref={scrollContainerRef}
117
- >
118
- {content}
119
-
120
- <div className={styles.scrollStub} ref={scrollRef as RefObject<HTMLDivElement>} />
121
- </Scroll>
122
- ) : (
123
- <>{content}</>
124
- )}
125
-
126
- {Number(pinBottom?.length) > 0 && <PinBottomGroupItem>{itemsPinBottomJSX}</PinBottomGroupItem>}
127
-
128
- {footer && <div className={styles.footer}>{footer}</div>}
129
- </ToggleGroup>
102
+ {(Number(pinTop?.length) > 0 || search) && (
103
+ <PinTopGroupItem>
104
+ {search && <SearchItem search={search} />}
105
+
106
+ {Number(pinTop?.length) > 0 && itemsPinTopJSX}
107
+ </PinTopGroupItem>
108
+ )}
109
+
110
+ {scroll ? (
111
+ <Scroll
112
+ className={cn({
113
+ [commonStyles.scrollContainerS]: scroll && limitedScrollHeight && size === 's',
114
+ [commonStyles.scrollContainerM]: scroll && limitedScrollHeight && size === 'm',
115
+ [commonStyles.scrollContainerL]: scroll && limitedScrollHeight && size === 'l',
116
+ })}
117
+ barHideStrategy='never'
118
+ size={size === 's' ? 's' : 'm'}
119
+ ref={scrollContainerRef}
120
+ >
121
+ {content}
122
+
123
+ <div className={styles.scrollStub} ref={scrollRef as RefObject<HTMLDivElement>} />
124
+ </Scroll>
125
+ ) : (
126
+ <>{content}</>
127
+ )}
128
+
129
+ {Number(pinBottom?.length) > 0 && <PinBottomGroupItem>{itemsPinBottomJSX}</PinBottomGroupItem>}
130
+
131
+ {footer && <div className={styles.footer}>{footer}</div>}
130
132
  </ul>
131
133
  );
132
134
 
@@ -1,9 +1,9 @@
1
1
  import { createContext, ReactNode, useCallback, useContext } from 'react';
2
2
  import { useUncontrolledProp } from 'uncontrollable';
3
3
 
4
- type SelectionSingleValueType = string | number | undefined;
4
+ export type SelectionSingleValueType = string | number | undefined;
5
5
 
6
- type SelectionSingleState = {
6
+ export type SelectionSingleState = {
7
7
  /** Начальное состояние */
8
8
  defaultValue?: SelectionSingleValueType;
9
9
  /** Controlled состояние */
@@ -24,7 +24,7 @@ export type SelectionSingleProps = {
24
24
  isSelectionMultiple: false;
25
25
  } & SelectionSingleState;
26
26
 
27
- type SelectionMultipleState = {
27
+ export type SelectionMultipleState = {
28
28
  /** Начальное состояние */
29
29
  defaultValue?: SelectionSingleValueType[];
30
30
  /** Controlled состояние */
@@ -128,12 +128,10 @@ function SelectionMultipleProvider({
128
128
  onChange: onChangeProp,
129
129
  children,
130
130
  }: SelectionMultipleProps & Child) {
131
- const [value, setValue] = useUncontrolledProp<SelectionSingleValueType[]>(valueProp, defaultValue, cb => {
132
- onChangeProp?.(cb(value));
133
- });
131
+ const [value, setValue] = useUncontrolledProp<SelectionSingleValueType[]>(valueProp, defaultValue, onChangeProp);
134
132
 
135
133
  const onChange = useCallback(
136
- (newValue: SelectionSingleValueType) =>
134
+ (newValue: SelectionSingleValueType) => {
137
135
  setValue((oldValues: SelectionSingleValueType[]) => {
138
136
  if (Array.isArray(oldValues)) {
139
137
  if (oldValues.includes(newValue)) {
@@ -143,8 +141,13 @@ function SelectionMultipleProvider({
143
141
  return oldValues.concat(newValue);
144
142
  }
145
143
 
144
+ if (oldValues === undefined) {
145
+ return Array.isArray(newValue) ? newValue : [newValue];
146
+ }
147
+
146
148
  return undefined;
147
- }),
149
+ });
150
+ },
148
151
  [setValue],
149
152
  );
150
153
 
@@ -3,8 +3,11 @@ export * from './List';
3
3
 
4
4
  export type { ListProps, DroplistProps } from './types';
5
5
  export type {
6
+ SelectionSingleState,
7
+ SelectionMultipleState,
6
8
  SelectionSingleProps,
7
9
  SelectionMultipleProps,
8
10
  isSelectionSingleProps,
9
11
  isSelectionMultipleProps,
12
+ SelectionSingleValueType,
10
13
  } from './contexts';
@@ -64,15 +64,3 @@
64
64
  height: auto;
65
65
  max-height: calc($dimension-1m * 48); // 384px
66
66
  }
67
-
68
- .infoBlock {
69
- @include composite-var($sans-body-s);
70
-
71
- display: flex;
72
- align-items: center;
73
- justify-content: center;
74
-
75
- padding: $dimension-1m;
76
-
77
- color: $sys-neutral-text-support;
78
- }
@@ -3,6 +3,7 @@ import { FocusEvent, KeyboardEvent, ReactNode, RefObject } from 'react';
3
3
  import { DropdownProps } from '@snack-uikit/dropdown';
4
4
  import { WithSupportProps } from '@snack-uikit/utils';
5
5
 
6
+ import { EmptyStateProps } from '../../helperComponents';
6
7
  import { ScrollProps, SearchState } from '../../types';
7
8
  import { ItemProps } from '../Items';
8
9
  import { ListContextPrivateType, ListContextType, SelectionState } from './contexts';
@@ -13,6 +14,18 @@ type CollapseState = {
13
14
  defaultValue?: (string | number)[];
14
15
  };
15
16
 
17
+ export type EmptyState = {
18
+ dataFiltered?: boolean;
19
+ dataError?: boolean;
20
+
21
+ /** Экран при отстутствии данных */
22
+ noDataState?: EmptyStateProps;
23
+ /** Экран при отстутствии результатов поиска или фильтров */
24
+ noResultsState?: EmptyStateProps;
25
+ /** Экран при ошибке запроса */
26
+ errorDataState?: EmptyStateProps;
27
+ };
28
+
16
29
  export type ListProps = WithSupportProps<
17
30
  {
18
31
  /** Основные элементы списка */
@@ -23,19 +36,14 @@ export type ListProps = WithSupportProps<
23
36
  pinBottom?: ItemProps[];
24
37
  /**
25
38
  * Кастомизируемый элемент в конце списка
26
- * @type ReactElement
39
+ * @type ReactNode;
27
40
  */
28
41
  footer?: ReactNode;
29
42
  /** Список ссылок на кастомные элементы, помещенные в специальную секцию внизу списка */
30
43
  footerActiveElementsRefs?: RefObject<HTMLElement>[];
31
44
  /** Настройки поисковой строки */
32
45
  search?: SearchState;
33
- /** Флаг, отвещающий за состояние загрузки списка */
34
- loading?: boolean;
35
- /** Текст для состояния "Отсутсвие данных" */
36
- noData?: string;
37
- /** Текст для состояния "Отсутсвие результата" при поиске */
38
- noResults?: string;
46
+
39
47
  /** Tab Index */
40
48
  tabIndex?: number;
41
49
  /** Настройки раскрытия элементов */
@@ -43,16 +51,25 @@ export type ListProps = WithSupportProps<
43
51
  /** CSS-класс */
44
52
  className?: string;
45
53
  onKeyDown?(e: KeyboardEvent<HTMLElement>): void;
54
+
55
+ /** Флаг, отвещающий за состояние загрузки списка */
56
+ loading?: boolean;
46
57
  } & SelectionState &
47
58
  ListContextType &
48
- ScrollProps
59
+ ScrollProps &
60
+ EmptyState
49
61
  >;
50
62
 
51
63
  export type DroplistProps = {
52
64
  /** Ссылка на элемент-триггер для дроплиста */
53
65
  triggerElemRef?: RefObject<HTMLElement>;
54
- /** Триггер для дроплиста */
55
- children?: ReactNode;
66
+
67
+ /** Триггер для дроплиста
68
+ * @type ReactNode | ({onKeyDown}) => ReactNode
69
+ *
70
+ * Рендер функция принимает аргументы `onKeyDown` - хендлер ввода, для поддержки управления с клавиатуры
71
+ */
72
+ children: ReactNode | ((params: { onKeyDown?: (e: KeyboardEvent<HTMLElement>) => void }) => ReactNode);
56
73
  } & Pick<DropdownProps, 'trigger' | 'placement' | 'widthStrategy' | 'open' | 'onOpenChange'> &
57
74
  Omit<ListProps, 'tabIndex' | 'onKeyDown'>;
58
75
 
@@ -1,3 +1,5 @@
1
1
  export * from './Lists';
2
2
 
3
3
  export type { AccordionItemProps, NextListItemProps, BaseItemProps, GroupItemProps, ItemProps } from './Items/types';
4
+
5
+ export { useGroupItemSelection } from './Items/hooks';
@@ -0,0 +1,35 @@
1
+ import { InfoBlock, InfoBlockProps } from '@snack-uikit/info-block';
2
+
3
+ import styles from './styles.module.scss';
4
+
5
+ export type EmptyStateProps = Pick<InfoBlockProps, 'description' | 'icon' | 'data-test-id'>;
6
+
7
+ export type ListEmptyState = {
8
+ emptyStates: {
9
+ noDataState: EmptyStateProps;
10
+ noResultsState: EmptyStateProps;
11
+ errorDataState: EmptyStateProps;
12
+ };
13
+ loading?: boolean;
14
+ dataError?: boolean;
15
+ dataFiltered?: boolean;
16
+ itemsLength: number;
17
+ };
18
+
19
+ export function ListEmptyState({ dataError, dataFiltered, itemsLength, emptyStates, loading }: ListEmptyState) {
20
+ if (itemsLength || loading) {
21
+ return null;
22
+ }
23
+
24
+ return (
25
+ <div className={styles.listEmptyStateWrapper}>
26
+ {dataError && <InfoBlock {...emptyStates.errorDataState} size='s' align='vertical' />}
27
+ {!dataError && dataFiltered && (
28
+ <InfoBlock {...emptyStates.noResultsState} size='s' align='vertical' data-test-id='list__no-results' />
29
+ )}
30
+ {!dataError && !dataFiltered && (
31
+ <InfoBlock {...emptyStates.noDataState} size='s' align='vertical' data-test-id='list__no-data' />
32
+ )}
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,42 @@
1
+ import { useMemo } from 'react';
2
+
3
+ import { CrossSVG } from '@snack-uikit/icons';
4
+ import { useLocale } from '@snack-uikit/locale';
5
+
6
+ import { EmptyStateProps } from './ListEmptyState';
7
+
8
+ export function useEmptyState({
9
+ noDataState: noDataStateProp,
10
+ noResultsState: noResultsStateProp,
11
+ errorDataState: errorDataStateProp,
12
+ }: {
13
+ noDataState?: EmptyStateProps;
14
+ noResultsState?: EmptyStateProps;
15
+ errorDataState?: EmptyStateProps;
16
+ }) {
17
+ const { t } = useLocale('List');
18
+
19
+ return useMemo(() => {
20
+ const noDataState: EmptyStateProps = {
21
+ description: t('noData.description'),
22
+ ...noDataStateProp,
23
+ };
24
+
25
+ const noResultsState: EmptyStateProps = {
26
+ description: t('noResults.description'),
27
+ ...noResultsStateProp,
28
+ };
29
+
30
+ const errorDataState: EmptyStateProps = {
31
+ icon: { icon: CrossSVG, appearance: 'red', decor: true },
32
+ description: t('errorData.description'),
33
+ ...errorDataStateProp,
34
+ };
35
+
36
+ return {
37
+ noDataState,
38
+ noResultsState,
39
+ errorDataState,
40
+ };
41
+ }, [errorDataStateProp, noDataStateProp, noResultsStateProp, t]);
42
+ }
@@ -0,0 +1,2 @@
1
+ export * from './ListEmptyState';
2
+ export { useEmptyState } from './hooks';
@@ -0,0 +1,11 @@
1
+ @import '@snack-uikit/figma-tokens/build/scss/components/styles-tokens-element';
2
+
3
+ .listEmptyStateWrapper {
4
+ display: flex;
5
+ flex-direction: column;
6
+ align-items: center;
7
+ justify-content: center;
8
+
9
+ box-sizing: border-box;
10
+ padding: $dimension-1m;
11
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './CollapseBlockPrivate';
2
2
  export * from './Separator';
3
3
  export * from './HiddenTabButton';
4
+ export * from './ListEmptyState';
package/src/hooks.ts ADDED
@@ -0,0 +1,45 @@
1
+ import FuzzySearch from 'fuzzy-search';
2
+ import { useCallback } from 'react';
3
+
4
+ import { isGroupItemProps, ItemProps } from './components/Items';
5
+
6
+ function flattenItems(items: ItemProps[]): ItemProps[] {
7
+ const flattenedItems: ItemProps[] = [];
8
+
9
+ function flatten(item: ItemProps) {
10
+ if (!isGroupItemProps(item)) {
11
+ flattenedItems.push(item);
12
+ }
13
+
14
+ if ('items' in item) {
15
+ for (const nestedItem of item.items) {
16
+ flatten(nestedItem);
17
+ }
18
+ }
19
+ }
20
+
21
+ for (const item of items) {
22
+ flatten(item);
23
+ }
24
+
25
+ return flattenedItems;
26
+ }
27
+
28
+ const DEFAULT_MIN_SEARCH_INPUT_LENGTH = 2;
29
+
30
+ export function useFuzzySearch(items: ItemProps[], minSearchInputLength?: number) {
31
+ return useCallback(
32
+ (search: string) => {
33
+ const searcher = new FuzzySearch(
34
+ flattenItems(items),
35
+ ['content.option', 'content.caption', 'content.description', 'label'],
36
+ {},
37
+ );
38
+
39
+ return search.length > (minSearchInputLength ?? DEFAULT_MIN_SEARCH_INPUT_LENGTH)
40
+ ? searcher.search(search)
41
+ : items;
42
+ },
43
+ [items, minSearchInputLength],
44
+ );
45
+ }
package/src/index.ts CHANGED
@@ -1 +1,4 @@
1
1
  export * from './components';
2
+ export { useFuzzySearch } from './hooks';
3
+ export type { SearchState } from './types';
4
+ export { extractChildIds } from './utils';
package/src/utils.ts CHANGED
@@ -113,3 +113,28 @@ export function extractChildIds({ items }: { items: ItemProps[] }): Array<string
113
113
  [] as Array<string | number>,
114
114
  );
115
115
  }
116
+
117
+ export function extractAllChildIds({ items }: { items: ItemProps[] }): Array<string | number> {
118
+ return items
119
+ .filter(
120
+ item =>
121
+ isAccordionItemProps(item) ||
122
+ isNextListItemProps(item) ||
123
+ isGroupItemProps(item) ||
124
+ (isBaseItemProps(item) && !item.inactive),
125
+ )
126
+ .reduce(
127
+ (prev: Array<string | number>, item: ItemProps) => {
128
+ if (isAccordionItemProps(item) || isNextListItemProps(item)) {
129
+ return prev.concat([item.id ?? '']).concat(extractAllChildIds({ items: item.items }));
130
+ }
131
+
132
+ if (isGroupItemProps(item)) {
133
+ return prev.concat(extractAllChildIds({ items: item.items }));
134
+ }
135
+
136
+ return item.id && !isGroupItemProps(item) ? prev.concat([item.id]) : prev;
137
+ },
138
+ [] as Array<string | number>,
139
+ );
140
+ }