@aurora-is-near/intents-swap-widget 3.17.2 → 3.17.3

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 (135) hide show
  1. package/dist/{config-BVaLnxO9.js → config-VpsP2eLV.js} +1022 -958
  2. package/dist/{config-BVaLnxO9.js.map → config-VpsP2eLV.js.map} +1 -1
  3. package/dist/config.js +1 -1
  4. package/dist/errors.js +1 -1
  5. package/dist/ext/alchemy/index.js +1 -1
  6. package/dist/ext/index.js +1 -1
  7. package/dist/features/BalanceRpcLoader/TokenBalanceLoader.js +1 -1
  8. package/dist/features/BalanceRpcLoader/index.js +1 -1
  9. package/dist/features/BalanceRpcLoader/useTokenBalanceRpc.js +1 -1
  10. package/dist/features/ChainsDropdown/index.js +1 -1
  11. package/dist/features/DepositMethodSwitcher.js +1 -1
  12. package/dist/features/ErrorBoundary.js +1 -1
  13. package/dist/features/ExternalDeposit.js +1 -1
  14. package/dist/features/SendAddress/index.js +1 -1
  15. package/dist/features/SendAddress/useNotification.js +1 -1
  16. package/dist/features/SubmitButton/index.js +1 -1
  17. package/dist/features/SuccessScreen/index.js +1 -1
  18. package/dist/features/SwapDirectionSwitcher.js +1 -1
  19. package/dist/features/SwapQuote/SwapQuote.js +1 -1
  20. package/dist/features/SwapQuote/index.js +1 -1
  21. package/dist/features/TokenInput/TokenInput.js +1 -1
  22. package/dist/features/TokenInput/TokenInputEmpty.js +1 -1
  23. package/dist/features/TokenInput/TokenInputSource.js +1 -1
  24. package/dist/features/TokenInput/TokenInputTarget.js +1 -1
  25. package/dist/features/TokenInput/WalletBalance.js +1 -1
  26. package/dist/features/TokenInput/hooks/index.js +1 -1
  27. package/dist/features/TokenInput/hooks/useTokenInputBalance.js +1 -1
  28. package/dist/features/TokenInput/index.js +1 -1
  29. package/dist/features/TokensList/TokenItem.d.ts +2 -2
  30. package/dist/features/TokensList/TokenItem.js +1 -2
  31. package/dist/features/TokensList/TokensList.js +10 -2
  32. package/dist/features/TokensList/TokensList.js.map +1 -1
  33. package/dist/features/TokensList/constants.d.ts +4 -0
  34. package/dist/features/TokensList/constants.js +8 -0
  35. package/dist/features/TokensList/constants.js.map +1 -0
  36. package/dist/features/TokensList/hooks/index.d.ts +1 -0
  37. package/dist/features/TokensList/hooks/index.js +5 -0
  38. package/dist/features/TokensList/hooks/index.js.map +1 -0
  39. package/dist/features/TokensList/hooks/useFocusOnList.d.ts +9 -0
  40. package/dist/features/TokensList/hooks/useFocusOnList.js +33 -0
  41. package/dist/features/TokensList/hooks/useFocusOnList.js.map +1 -0
  42. package/dist/features/TokensList/index.d.ts +2 -1
  43. package/dist/features/TokensList/index.js +1 -1
  44. package/dist/features/TokensList/types.d.ts +14 -0
  45. package/dist/features/TokensList/types.js +2 -0
  46. package/dist/features/TokensList/types.js.map +1 -0
  47. package/dist/features/TokensList/utils/getFirstGroupItemTotalIndex.d.ts +9 -0
  48. package/dist/features/TokensList/utils/getFirstGroupItemTotalIndex.js +8 -0
  49. package/dist/features/TokensList/utils/getFirstGroupItemTotalIndex.js.map +1 -0
  50. package/dist/features/TokensList/utils/getGroupHeadersTotalIndexes.d.ts +8 -0
  51. package/dist/features/TokensList/utils/getGroupHeadersTotalIndexes.js +5 -0
  52. package/dist/features/TokensList/utils/getGroupHeadersTotalIndexes.js.map +1 -0
  53. package/dist/features/TokensList/utils/getListItemsTotalCount.d.ts +8 -0
  54. package/dist/features/TokensList/utils/getListItemsTotalCount.js +8 -0
  55. package/dist/features/TokensList/utils/getListItemsTotalCount.js.map +1 -0
  56. package/dist/features/TokensList/utils/getListState.d.ts +9 -0
  57. package/dist/features/TokensList/utils/getListState.js +5 -0
  58. package/dist/features/TokensList/utils/getListState.js.map +1 -0
  59. package/dist/features/TokensList/utils/getListTotalHeight.d.ts +8 -0
  60. package/dist/features/TokensList/utils/getListTotalHeight.js +12 -0
  61. package/dist/features/TokensList/utils/getListTotalHeight.js.map +1 -0
  62. package/dist/features/TokensList/utils/getTokenByTotalIndex.d.ts +9 -0
  63. package/dist/features/TokensList/utils/getTokenByTotalIndex.js +7 -0
  64. package/dist/features/TokensList/utils/getTokenByTotalIndex.js.map +1 -0
  65. package/dist/features/TokensList/utils/index.d.ts +6 -0
  66. package/dist/features/TokensList/utils/index.js +15 -0
  67. package/dist/features/TokensList/utils/index.js.map +1 -0
  68. package/dist/features/TokensModal.js +1 -1
  69. package/dist/features/WalletCompatibilityCheck/WalletCompatibilityModal.js +1 -1
  70. package/dist/features/WalletCompatibilityCheck/index.js +1 -1
  71. package/dist/features/index.js +1 -1
  72. package/dist/hooks/index.js +1 -1
  73. package/dist/hooks/useAllTokens.js +1 -1
  74. package/dist/hooks/useChains.js +1 -1
  75. package/dist/hooks/useCompatibilityCheck.js +1 -1
  76. package/dist/hooks/useDefaultToken.js +1 -1
  77. package/dist/hooks/useExternalDepositStatus/index.js +1 -1
  78. package/dist/hooks/useExternalDepositStatus/usePoaExternalDepositStatus.js +1 -1
  79. package/dist/hooks/useIntentsBalance.js +1 -1
  80. package/dist/hooks/useIsCompatibilityCheckRequired.js +1 -1
  81. package/dist/hooks/useMakeDepositAddress.js +1 -1
  82. package/dist/hooks/useMakeIntentsTransfer.js +1 -1
  83. package/dist/hooks/useMakeNEARFtTransferCall.js +1 -1
  84. package/dist/hooks/useMakeQuote.js +1 -1
  85. package/dist/hooks/useMakeQuoteTransfer.js +1 -1
  86. package/dist/hooks/useMakeTransfer.js +1 -1
  87. package/dist/hooks/useMergedBalance.js +1 -1
  88. package/dist/hooks/useSwitchChain.js +1 -1
  89. package/dist/hooks/useTheme.js +1 -1
  90. package/dist/hooks/useTokenInputPair.js +1 -1
  91. package/dist/hooks/useTokens.js +1 -1
  92. package/dist/hooks/useTokensFiltered.js +1 -1
  93. package/dist/hooks/useTokensIntentsUnique.js +1 -1
  94. package/dist/index.js +1 -1
  95. package/dist/machine/effects/index.js +1 -1
  96. package/dist/machine/effects/useAlchemyBalanceEffect.js +1 -1
  97. package/dist/machine/effects/useBalancesUpdateEffect.js +1 -1
  98. package/dist/machine/effects/useMakeQuoteEffect.js +1 -1
  99. package/dist/machine/effects/useSelectedTokensEffect.js +1 -1
  100. package/dist/machine/effects/useSetTokenBalanceEffect.js +1 -1
  101. package/dist/machine/effects/useSetTokenIntentsTargetEffect.js +1 -1
  102. package/dist/machine/effects/useWalletConnEffect.js +1 -1
  103. package/dist/machine/events/index.js +1 -1
  104. package/dist/machine/events/tokenSelect.js +1 -1
  105. package/dist/machine/events/validateInputAndMoveTo.js +1 -1
  106. package/dist/machine/events/validateInputs.js +1 -1
  107. package/dist/machine/index.js +1 -1
  108. package/dist/machine/snap.js +1 -1
  109. package/dist/machine/subscriptions/checkers/isSendAddressAsConnected.js +1 -1
  110. package/dist/machine/subscriptions/index.js +1 -1
  111. package/dist/styles.css +1 -1
  112. package/dist/theme/ThemeProvider.js +1 -1
  113. package/dist/utils/intents/signers/near.js +1 -1
  114. package/dist/utils/intents/signers/privy.js +1 -1
  115. package/dist/utils/near/getNearNep141StorageBalance.js +1 -1
  116. package/dist/widgets/WidgetDeposit/WidgetDepositContent.js +1 -1
  117. package/dist/widgets/WidgetDeposit/WidgetDepositSkeleton.js +1 -1
  118. package/dist/widgets/WidgetSwap/WidgetSwapContent.js +1 -1
  119. package/dist/widgets/WidgetSwap/WidgetSwapSkeleton.js +1 -1
  120. package/dist/widgets/WidgetWithdraw/WidgetWithdrawContent.js +1 -1
  121. package/dist/widgets/WidgetWithdraw/WidgetWithdrawSkeleton.js +1 -1
  122. package/package.json +1 -1
  123. package/src/features/TokensList/TokenItem.tsx +7 -3
  124. package/src/features/TokensList/TokensList.tsx +151 -57
  125. package/src/features/TokensList/constants.ts +4 -0
  126. package/src/features/TokensList/hooks/index.ts +1 -0
  127. package/src/features/TokensList/hooks/useFocusOnList.ts +56 -0
  128. package/src/features/TokensList/types.ts +28 -0
  129. package/src/features/TokensList/utils/getFirstGroupItemTotalIndex.ts +28 -0
  130. package/src/features/TokensList/utils/getGroupHeadersTotalIndexes.ts +21 -0
  131. package/src/features/TokensList/utils/getListItemsTotalCount.ts +14 -0
  132. package/src/features/TokensList/utils/getListState.ts +16 -0
  133. package/src/features/TokensList/utils/getListTotalHeight.ts +25 -0
  134. package/src/features/TokensList/utils/getTokenByTotalIndex.ts +19 -0
  135. package/src/features/TokensList/utils/index.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurora-is-near/intents-swap-widget",
3
- "version": "3.17.2",
3
+ "version": "3.17.3",
4
4
  "description": "Provides components and hooks to build your own Intents swap widget",
5
5
  "author": "Maksim Vashchuk",
6
6
  "license": "MIT",
@@ -6,8 +6,6 @@ import { TinyNumber } from '@/components/TinyNumber';
6
6
  import { getUsdDisplayBalance } from '@/utils/formatters/getUsdDisplayBalance';
7
7
  import type { Token, TokenBalance } from '@/types/token';
8
8
 
9
- export const TOKEN_ITEM_HEIGHT = 58;
10
-
11
9
  type Msg = { type: 'on_select_token'; token: Token };
12
10
 
13
11
  type Props = {
@@ -15,6 +13,7 @@ type Props = {
15
13
  balance: TokenBalance;
16
14
  showBalance?: boolean;
17
15
  isNotSelectable?: boolean;
16
+ isFocused?: boolean;
18
17
  className?: string;
19
18
  onMsg: (msg: Msg) => void;
20
19
  };
@@ -24,6 +23,7 @@ export const TokenItem = ({
24
23
  balance,
25
24
  showBalance = true,
26
25
  isNotSelectable,
26
+ isFocused,
27
27
  className,
28
28
  onMsg,
29
29
  }: Props) => {
@@ -43,6 +43,7 @@ export const TokenItem = ({
43
43
  {
44
44
  'cursor-not-allowed': isNotSelectable,
45
45
  'cursor-pointer hover:bg-sw-gray-800': !isNotSelectable,
46
+ 'bg-sw-gray-800': isFocused && !isNotSelectable,
46
47
  },
47
48
  className,
48
49
  )}
@@ -52,7 +53,10 @@ export const TokenItem = ({
52
53
  <TokenIcon
53
54
  token={token}
54
55
  chainShowIcon={!token.isIntent}
55
- className="border-sw-gray-900 group-hover:border-sw-gray-800 transition-colors"
56
+ className={cn('group-hover:border-sw-gray-800 transition-colors', {
57
+ 'border-sw-gray-800': isFocused,
58
+ 'border-sw-gray-900': !isFocused,
59
+ })}
56
60
  />
57
61
 
58
62
  <div className="gap-sw-xs mr-auto flex flex-col">
@@ -1,8 +1,24 @@
1
- import { VList } from 'virtua';
2
- import { Fragment, useMemo } from 'react';
1
+ import { VList, VListHandle } from 'virtua';
2
+ import { useCallback, useMemo, useRef, useState } from 'react';
3
3
 
4
- import { TOKEN_ITEM_HEIGHT, TokenItem } from './TokenItem';
4
+ import { TokenItem } from './TokenItem';
5
+ import { useFocusOnList } from './hooks';
5
6
  import { TokensListPlaceholder } from './TokensListPlaceholder';
7
+ import {
8
+ LIST_CONTAINER_ID,
9
+ MAX_LIST_VIEW_AREA_HEIGHT,
10
+ TOKEN_ITEM_HEIGHT,
11
+ } from './constants';
12
+ import {
13
+ getFirstGroupItemTotalIndex,
14
+ getGroupHeadersTotalIndexes,
15
+ getListItemsTotalCount,
16
+ getListState,
17
+ getListTotalHeight,
18
+ getTokenByTotalIndex,
19
+ } from './utils';
20
+ import type { ListGroup } from './types';
21
+
6
22
  import { cn } from '@/utils/cn';
7
23
  import { Hr } from '@/components/Hr';
8
24
  import { useUnsafeSnapshot } from '@/machine/snap';
@@ -31,18 +47,6 @@ type Props = {
31
47
  onMsg: (msg: Msg) => void;
32
48
  };
33
49
 
34
- const useListState = (tokens: ReadonlyArray<Token>, search: string) => {
35
- if (tokens.length === 0 && search) {
36
- return 'EMPTY_SEARCH';
37
- }
38
-
39
- if (tokens.length === 0 && !search) {
40
- return 'NO_TOKENS';
41
- }
42
-
43
- return 'HAS_TOKENS';
44
- };
45
-
46
50
  export const TokensList = ({
47
51
  variant,
48
52
  search,
@@ -68,37 +72,52 @@ export const TokensList = ({
68
72
  });
69
73
 
70
74
  const areTokensGrouped = ctx.walletAddress ? groupTokens : false;
71
- const tokensListState = useListState(filteredTokens.all, search);
75
+ const tokensListState = getListState(filteredTokens.all, search);
72
76
 
73
- const tokensUngrouped = useMemo(
74
- () => [{ label: null, tokens: filteredTokens.all }],
77
+ const ref = useRef<VListHandle>(null);
78
+ const [focusedIndex, setFocusedIndex] = useState(-1);
79
+
80
+ const tokensUngrouped = useMemo<ListGroup<1>>(
81
+ () => [{ tokens: filteredTokens.all }],
75
82
  [filteredTokens.all],
76
83
  );
77
84
 
78
- const tokensBySection = useMemo(
79
- () => [
80
- { label: appName, tokens: filteredTokens.intents },
81
- {
82
- label: chainIsNotSupported ? null : 'Connected wallet',
83
- tokens: filteredTokens.wallet,
84
- },
85
- ],
86
- [filteredTokens.wallet, filteredTokens.intents, chainIsNotSupported],
87
- );
85
+ const tokensBySection = useMemo(() => {
86
+ return [
87
+ ...(filteredTokens.intents.length > 0
88
+ ? [
89
+ { label: appName, count: filteredTokens.intents.length },
90
+ { tokens: filteredTokens.intents },
91
+ ]
92
+ : []),
93
+ ...(filteredTokens.wallet.length > 0
94
+ ? [
95
+ {
96
+ label: chainIsNotSupported ? null : 'Connected wallet',
97
+ count: filteredTokens.wallet.length,
98
+ },
99
+ { tokens: filteredTokens.wallet },
100
+ ]
101
+ : []),
102
+ ].filter(Boolean) as ListGroup<0 | 2 | 4>;
103
+ }, [filteredTokens.wallet, filteredTokens.intents, chainIsNotSupported]);
88
104
 
89
- const tokensCount = useMemo(() => {
90
- return (areTokensGrouped ? tokensBySection : tokensUngrouped).reduce(
91
- (acc, group) => acc + group.tokens.length,
92
- 0,
93
- );
94
- }, [tokensBySection, tokensUngrouped, areTokensGrouped]);
105
+ const tokensList = areTokensGrouped ? tokensBySection : tokensUngrouped;
95
106
 
96
- // <offset> - user defined offset e.g. page header height + space
97
- // 152px - height of TokenModal elements like search and paddings
98
- // 48px - minimal offset from the bottom screen edge
99
- // Total: 152 + 48 = 200px
100
- // const maxHeight = `calc(100vh - (${topScreenOffset ?? '0px'} + ${offset ?? '0px'} + 200px))`;
101
- const maxHeight = '450px';
107
+ const listHeight = getListTotalHeight(tokensList);
108
+ const totalItems = getListItemsTotalCount(tokensList);
109
+ const headerIndexes = getGroupHeadersTotalIndexes(tokensList);
110
+
111
+ const handleBlur = useCallback(() => {
112
+ setFocusedIndex(-1);
113
+ }, []);
114
+
115
+ useFocusOnList({
116
+ listRef: ref.current,
117
+ initialFocusedIndex: areTokensGrouped ? 1 : 0,
118
+ onFocus: (index) => setFocusedIndex(index),
119
+ onBlur: handleBlur,
120
+ });
102
121
 
103
122
  switch (tokensListState) {
104
123
  case 'EMPTY_SEARCH':
@@ -121,26 +140,98 @@ export const TokensList = ({
121
140
  return (
122
141
  <div className={cn('gap-sw-lg flex flex-col', className)}>
123
142
  <VList
143
+ ref={ref}
144
+ tabIndex={0}
145
+ id={LIST_CONTAINER_ID}
146
+ itemSize={TOKEN_ITEM_HEIGHT}
124
147
  className="hide-scrollbar"
125
148
  style={{
126
- maxHeight,
127
149
  minHeight: 200,
128
- height: tokensCount
129
- ? tokensCount * TOKEN_ITEM_HEIGHT +
130
- (areTokensGrouped ? tokensBySection.length * 62 : 0)
131
- : TOKEN_ITEM_HEIGHT * 2,
150
+ height: listHeight,
151
+ maxHeight: MAX_LIST_VIEW_AREA_HEIGHT,
152
+ overflowAnchor: 'none',
153
+ outline: 'none',
154
+ }}
155
+ onKeyDown={(e) => {
156
+ if (!ref.current) {
157
+ return;
158
+ }
159
+
160
+ switch (e.code) {
161
+ case 'ArrowUp': {
162
+ e.preventDefault();
163
+ let prevIndex = Math.max(focusedIndex - 1, 0);
164
+
165
+ if (areTokensGrouped && headerIndexes.includes(prevIndex)) {
166
+ prevIndex = prevIndex === 0 ? 1 : prevIndex - 1;
167
+ }
168
+
169
+ setFocusedIndex(prevIndex);
170
+ ref.current?.scrollToIndex(prevIndex, {
171
+ align: 'center',
172
+ smooth: true,
173
+ });
174
+
175
+ break;
176
+ }
177
+
178
+ case 'ArrowDown': {
179
+ e.preventDefault();
180
+ let nextIndex = Math.min(focusedIndex + 1, totalItems - 1);
181
+
182
+ if (areTokensGrouped && headerIndexes.includes(nextIndex)) {
183
+ nextIndex = nextIndex === 0 ? 1 : nextIndex + 1;
184
+ }
185
+
186
+ setFocusedIndex(nextIndex);
187
+ ref.current?.scrollToIndex(nextIndex, {
188
+ align: 'center',
189
+ smooth: true,
190
+ });
191
+
192
+ break;
193
+ }
194
+
195
+ case 'Enter': {
196
+ e.preventDefault();
197
+ const token = getTokenByTotalIndex(tokensList, focusedIndex);
198
+
199
+ if (token) {
200
+ onMsg({ type: 'on_select_token', token });
201
+ }
202
+
203
+ break;
204
+ }
205
+
206
+ default:
207
+ break;
208
+ }
132
209
  }}>
133
- {(areTokensGrouped ? tokensBySection : tokensUngrouped).map(
134
- ({ label, tokens: tokensToDisplay }) => (
135
- <Fragment key={label ?? 'ungrouped-tokens'}>
136
- {tokensToDisplay.length && label ? (
137
- <header className="pb-sw-lg flex flex-col">
210
+ {tokensList.map(
211
+ ({ label, count, tokens: tokensToDisplay }, groupIndex) => {
212
+ const firstItemInGroupIndex = getFirstGroupItemTotalIndex(
213
+ tokensList,
214
+ groupIndex,
215
+ );
216
+
217
+ if (label !== undefined) {
218
+ if (!label) {
219
+ // must be rendered for calculations even if label is null
220
+ return <header key={groupIndex} />;
221
+ }
222
+
223
+ return (
224
+ <header
225
+ key={label}
226
+ className="pb-sw-lg pt-sw-sm flex flex-col">
138
227
  <Hr />
139
- <span className="text-sw-label-sm pt-sw-xl text-sw-gray-100">{`${label} — ${tokensToDisplay.length}`}</span>
228
+ <span className="text-sw-label-sm pt-sw-xl text-sw-gray-100">{`${label} — ${count}`}</span>
140
229
  </header>
141
- ) : null}
230
+ );
231
+ }
142
232
 
143
- {tokensToDisplay.map((token) => {
233
+ if (tokensToDisplay) {
234
+ return tokensToDisplay.map((token, tokenIndex) => {
144
235
  const tokenBalanceKey = getTokenBalanceKey(token);
145
236
 
146
237
  return (
@@ -149,17 +240,20 @@ export const TokensList = ({
149
240
  key={tokenBalanceKey}
150
241
  showBalance={showBalances}
151
242
  balance={mergedBalance[tokenBalanceKey]}
243
+ isFocused={
244
+ focusedIndex === firstItemInGroupIndex + tokenIndex
245
+ }
152
246
  isNotSelectable={
153
247
  chainIsNotSupported && !!ctx.walletAddress
154
248
  }
155
249
  onMsg={onMsg}
156
250
  />
157
251
  );
158
- })}
252
+ });
253
+ }
159
254
 
160
- {tokensToDisplay.length ? <div className="h-sw-2xl" /> : null}
161
- </Fragment>
162
- ),
255
+ return null;
256
+ },
163
257
  )}
164
258
  </VList>
165
259
  </div>
@@ -0,0 +1,4 @@
1
+ export const MAX_LIST_VIEW_AREA_HEIGHT = '450px';
2
+ export const LIST_CONTAINER_ID = 'virtual-tokens-list';
3
+ export const TOKEN_ITEM_HEIGHT = 58;
4
+ export const LIST_SECTION_HEADER_HEIGHT = 62;
@@ -0,0 +1 @@
1
+ export { useFocusOnList } from './useFocusOnList';
@@ -0,0 +1,56 @@
1
+ import { useCallback, useEffect } from 'react';
2
+ import type { VListHandle } from 'virtua';
3
+
4
+ import { LIST_CONTAINER_ID } from '../constants';
5
+
6
+ import { useHandleKeyDown } from '@/hooks/useHandleKeyDown';
7
+
8
+ type Args = {
9
+ initialFocusedIndex: number;
10
+ listRef: VListHandle | null;
11
+ onFocus: (index: number) => void;
12
+ onBlur: () => void;
13
+ };
14
+
15
+ export const useFocusOnList = ({
16
+ initialFocusedIndex,
17
+ listRef,
18
+ onFocus,
19
+ onBlur,
20
+ }: Args) => {
21
+ useHandleKeyDown('ArrowDown', () => {
22
+ const virtualListDiv = document.getElementById(LIST_CONTAINER_ID);
23
+
24
+ if (virtualListDiv && document.activeElement !== virtualListDiv) {
25
+ onFocus(initialFocusedIndex);
26
+ listRef?.scrollToIndex(initialFocusedIndex, { align: 'nearest' });
27
+
28
+ // required to prevent initial scroll on focus
29
+ // which causes list's jump
30
+ setTimeout(() => {
31
+ virtualListDiv.focus({ preventScroll: true });
32
+ }, 0);
33
+ }
34
+ });
35
+
36
+ const handleBlur = useCallback(
37
+ (event: FocusEvent) => {
38
+ const virtualListDiv = document.getElementById(LIST_CONTAINER_ID);
39
+
40
+ if (event.target === virtualListDiv) {
41
+ onBlur();
42
+ }
43
+ },
44
+ [onBlur],
45
+ );
46
+
47
+ useEffect(() => {
48
+ const virtualListDiv = document.getElementById(LIST_CONTAINER_ID);
49
+
50
+ virtualListDiv?.addEventListener('blur', handleBlur);
51
+
52
+ return () => {
53
+ virtualListDiv?.removeEventListener('blur', handleBlur);
54
+ };
55
+ }, [handleBlur]);
56
+ };
@@ -0,0 +1,28 @@
1
+ import type { Token } from '@/types/token';
2
+
3
+ type OddGroup = {
4
+ label: string | null;
5
+ count: number;
6
+ tokens?: never;
7
+ };
8
+
9
+ type EvenGroup = {
10
+ label?: never;
11
+ count?: never;
12
+ tokens: Token[];
13
+ };
14
+
15
+ // covers 3 sections max (extend if needed)
16
+ export type ListGroup<N extends number = 6> = N extends 0
17
+ ? []
18
+ : N extends 1
19
+ ? [EvenGroup]
20
+ : N extends 2
21
+ ? [OddGroup, EvenGroup]
22
+ : N extends 4
23
+ ? [OddGroup, EvenGroup, OddGroup, EvenGroup]
24
+ : N extends 6
25
+ ? [OddGroup, EvenGroup, OddGroup, EvenGroup, OddGroup, EvenGroup]
26
+ : never;
27
+
28
+ export type AnyListGroup = ListGroup<0 | 1 | 2 | 4 | 6>;
@@ -0,0 +1,28 @@
1
+ import type { AnyListGroup } from '../types';
2
+
3
+ /**
4
+ * Get index across all groups of the first item in a given group
5
+ *
6
+ * @param tokensList - The list of tokens (grouped or ungrouped)
7
+ * @param groupIndex - The index of the group
8
+ * @returns The total index of the first item in the group
9
+ */
10
+ export const getFirstGroupItemTotalIndex = (
11
+ tokensList: AnyListGroup,
12
+ groupIndex: number,
13
+ ) => {
14
+ const tokensGroupIndex = groupIndex % 2 !== 0 ? groupIndex : 0;
15
+
16
+ if (tokensGroupIndex <= 1) {
17
+ return tokensGroupIndex;
18
+ }
19
+
20
+ return (
21
+ tokensList.reduce((acc, group, index) => {
22
+ return (
23
+ acc +
24
+ (group.tokens && index < tokensGroupIndex ? group.tokens.length + 1 : 0)
25
+ );
26
+ }, 0) + 1
27
+ ); // +1 for current group's header
28
+ };
@@ -0,0 +1,21 @@
1
+ import type { AnyListGroup } from '../types';
2
+
3
+ /**
4
+ * Get positions of all group headers in the combined list
5
+ *
6
+ * @param tokensList - The list of tokens (grouped or ungrouped)
7
+ * @returns List of positions of all group headers in the combined list
8
+ */
9
+ export const getGroupHeadersTotalIndexes = (tokensList: AnyListGroup) => {
10
+ return tokensList.reduce<number[]>((acc, group, index) => {
11
+ if (index === 0 && group.label) {
12
+ return [0];
13
+ }
14
+
15
+ if (group.tokens && index < tokensList.length - 1) {
16
+ return [...acc, (acc[acc.length - 1] ?? 0) + group.tokens.length + 1];
17
+ }
18
+
19
+ return acc;
20
+ }, []);
21
+ };
@@ -0,0 +1,14 @@
1
+ import type { AnyListGroup } from '../types';
2
+
3
+ /**
4
+ * Get the total number of RENDERED items in the list (including group headers)
5
+ *
6
+ * @param tokensList - The list of tokens (grouped or ungrouped)
7
+ * @returns The total number of items in the list
8
+ */
9
+ export const getListItemsTotalCount = (tokensList: AnyListGroup) => {
10
+ return tokensList.reduce(
11
+ (acc, group) => acc + (group.tokens?.length ?? 0) + (group.label ? 1 : 0),
12
+ 0,
13
+ );
14
+ };
@@ -0,0 +1,16 @@
1
+ import type { Token } from '@/types/token';
2
+
3
+ /**
4
+ * Get the state of the list
5
+ *
6
+ * @param tokens - The list of tokens (grouped or ungrouped)
7
+ * @param search - The search string
8
+ * @returns The state of the list: 'EMPTY_SEARCH', 'NO_TOKENS', 'HAS_TOKENS'
9
+ */
10
+ export const getListState = (tokens: ReadonlyArray<Token>, search: string) => {
11
+ if (tokens.length) {
12
+ return 'HAS_TOKENS';
13
+ }
14
+
15
+ return !search ? 'NO_TOKENS' : 'EMPTY_SEARCH';
16
+ };
@@ -0,0 +1,25 @@
1
+ import { LIST_SECTION_HEADER_HEIGHT, TOKEN_ITEM_HEIGHT } from '../constants';
2
+ import type { AnyListGroup } from '../types';
3
+
4
+ const getTokensTotalCount = (tokensList: AnyListGroup) => {
5
+ return tokensList.reduce(
6
+ (acc, group) => acc + (group.tokens?.length ?? 0),
7
+ 0,
8
+ );
9
+ };
10
+
11
+ /**
12
+ * Get the total height of the list
13
+ *
14
+ * @param tokensList - The list of tokens (grouped or ungrouped)
15
+ * @returns The total height of the list
16
+ */
17
+ export const getListTotalHeight = (tokensList: AnyListGroup) => {
18
+ const tokensCount = getTokensTotalCount(tokensList);
19
+
20
+ return tokensCount
21
+ ? tokensCount * TOKEN_ITEM_HEIGHT +
22
+ tokensList.filter((group) => !!group.label).length *
23
+ LIST_SECTION_HEADER_HEIGHT
24
+ : TOKEN_ITEM_HEIGHT * 2;
25
+ };
@@ -0,0 +1,19 @@
1
+ import type { AnyListGroup } from '../types';
2
+
3
+ /**
4
+ * Get the token by total index across all groups and headers
5
+ *
6
+ * @param tokensList - The list of tokens (grouped or ungrouped)
7
+ * @param totalIndex - The total index of the token
8
+ * @returns The token
9
+ */
10
+ export const getTokenByTotalIndex = (
11
+ tokensList: AnyListGroup,
12
+ totalIndex: number,
13
+ ) => {
14
+ const flatList = tokensList.flatMap((group) =>
15
+ 'label' in group ? [null] : group.tokens,
16
+ );
17
+
18
+ return flatList[totalIndex];
19
+ };
@@ -0,0 +1,6 @@
1
+ export { getGroupHeadersTotalIndexes } from './getGroupHeadersTotalIndexes';
2
+ export { getFirstGroupItemTotalIndex } from './getFirstGroupItemTotalIndex';
3
+ export { getListItemsTotalCount } from './getListItemsTotalCount';
4
+ export { getTokenByTotalIndex } from './getTokenByTotalIndex';
5
+ export { getListTotalHeight } from './getListTotalHeight';
6
+ export { getListState } from './getListState';