UNPKG

12.1 kBJavaScriptView Raw
1import _extends from "@babel/runtime/helpers/esm/extends";
2import { ListActionTypes } from './listActions.types';
3/**
4 * Looks up the next valid item to highlight within the list.
5 *
6 * @param currentIndex The index of the start of the search.
7 * @param lookupDirection Whether to look for the next or previous item.
8 * @param items The array of items to search.
9 * @param includeDisabledItems Whether to include disabled items in the search.
10 * @param isItemDisabled A function that determines whether an item is disabled.
11 * @param wrapAround Whether to wrap around the list when searching.
12 * @returns The index of the next valid item to highlight or -1 if no valid item is found.
13 */
14function findValidItemToHighlight(currentIndex, lookupDirection, items, includeDisabledItems, isItemDisabled, wrapAround) {
15 if (items.length === 0 || !includeDisabledItems && items.every((item, itemIndex) => isItemDisabled(item, itemIndex))) {
16 return -1;
17 }
18 let nextFocus = currentIndex;
19 for (;;) {
20 // No valid items found
21 if (!wrapAround && lookupDirection === 'next' && nextFocus === items.length || !wrapAround && lookupDirection === 'previous' && nextFocus === -1) {
22 return -1;
23 }
24 const nextFocusDisabled = includeDisabledItems ? false : isItemDisabled(items[nextFocus], nextFocus);
25 if (nextFocusDisabled) {
26 nextFocus += lookupDirection === 'next' ? 1 : -1;
27 if (wrapAround) {
28 nextFocus = (nextFocus + items.length) % items.length;
29 }
30 } else {
31 return nextFocus;
32 }
33 }
34}
35
36/**
37 * Gets the next item to highlight based on the current highlighted item and the search direction.
38 *
39 * @param previouslyHighlightedValue The item from which to start the search for the next candidate.
40 * @param offset The offset from the previously highlighted item to search for the next candidate or a special named value ('reset', 'start', 'end').
41 * @param context The list action context.
42 *
43 * @returns The next item to highlight or null if no item is valid.
44 */
45export function moveHighlight(previouslyHighlightedValue, offset, context) {
46 const {
47 items,
48 isItemDisabled,
49 disableListWrap,
50 disabledItemsFocusable,
51 itemComparer,
52 focusManagement
53 } = context;
54
55 // TODO: make this configurable
56 // The always should be an item highlighted when focus is managed by the DOM
57 // so that it's accessible by the `tab` key.
58 const defaultHighlightedIndex = focusManagement === 'DOM' ? 0 : -1;
59 const maxIndex = items.length - 1;
60 const previouslyHighlightedIndex = previouslyHighlightedValue == null ? -1 : items.findIndex(item => itemComparer(item, previouslyHighlightedValue));
61 let nextIndexCandidate;
62 let lookupDirection;
63 let wrapAround = !disableListWrap;
64 switch (offset) {
65 case 'reset':
66 if (defaultHighlightedIndex === -1) {
67 return null;
68 }
69 nextIndexCandidate = 0;
70 lookupDirection = 'next';
71 wrapAround = false;
72 break;
73 case 'start':
74 nextIndexCandidate = 0;
75 lookupDirection = 'next';
76 wrapAround = false;
77 break;
78 case 'end':
79 nextIndexCandidate = maxIndex;
80 lookupDirection = 'previous';
81 wrapAround = false;
82 break;
83 default:
84 {
85 const newIndex = previouslyHighlightedIndex + offset;
86 if (newIndex < 0) {
87 if (!wrapAround && previouslyHighlightedIndex !== -1 || Math.abs(offset) > 1) {
88 nextIndexCandidate = 0;
89 lookupDirection = 'next';
90 } else {
91 nextIndexCandidate = maxIndex;
92 lookupDirection = 'previous';
93 }
94 } else if (newIndex > maxIndex) {
95 if (!wrapAround || Math.abs(offset) > 1) {
96 nextIndexCandidate = maxIndex;
97 lookupDirection = 'previous';
98 } else {
99 nextIndexCandidate = 0;
100 lookupDirection = 'next';
101 }
102 } else {
103 nextIndexCandidate = newIndex;
104 lookupDirection = offset >= 0 ? 'next' : 'previous';
105 }
106 }
107 }
108 const nextIndex = findValidItemToHighlight(nextIndexCandidate, lookupDirection, items, disabledItemsFocusable, isItemDisabled, wrapAround);
109
110 // If there are no valid items to highlight, return the previously highlighted item (if it's still valid).
111 if (nextIndex === -1 && previouslyHighlightedValue !== null && !isItemDisabled(previouslyHighlightedValue, previouslyHighlightedIndex)) {
112 return previouslyHighlightedValue;
113 }
114 return items[nextIndex] ?? null;
115}
116
117/**
118 * Toggles the selection of an item.
119 *
120 * @param item Item to toggle.
121 * @param selectedValues Already selected items.
122 * @param selectionMode The number of items that can be simultanously selected.
123 * @param itemComparer A custom item comparer function.
124 *
125 * @returns The new array of selected items.
126 */
127export function toggleSelection(item, selectedValues, selectionMode, itemComparer) {
128 if (selectionMode === 'none') {
129 return [];
130 }
131 if (selectionMode === 'single') {
132 // if the item to select has already been selected, return the original array
133 if (itemComparer(selectedValues[0], item)) {
134 return selectedValues;
135 }
136 return [item];
137 }
138
139 // The toggled item is selected; remove it from the selection.
140 if (selectedValues.some(sv => itemComparer(sv, item))) {
141 return selectedValues.filter(sv => !itemComparer(sv, item));
142 }
143
144 // The toggled item is not selected - add it to the selection.
145 return [...selectedValues, item];
146}
147
148/**
149 * Handles item selection in a list.
150 *
151 * @param item - The item to be selected.
152 * @param state - The current state of the list.
153 * @param context - The context of the list action.
154 * @returns The new state of the list after the item has been selected, or the original state if the item is disabled.
155 */
156export function handleItemSelection(item, state, context) {
157 const {
158 itemComparer,
159 isItemDisabled,
160 selectionMode,
161 items
162 } = context;
163 const {
164 selectedValues
165 } = state;
166 const itemIndex = items.findIndex(i => itemComparer(item, i));
167 if (isItemDisabled(item, itemIndex)) {
168 return state;
169 }
170
171 // if the item is already selected, remove it from the selection, otherwise add it
172 const newSelectedValues = toggleSelection(item, selectedValues, selectionMode, itemComparer);
173 return _extends({}, state, {
174 selectedValues: newSelectedValues,
175 highlightedValue: item
176 });
177}
178function handleKeyDown(key, state, context) {
179 const previouslySelectedValue = state.highlightedValue;
180 const {
181 orientation,
182 pageSize
183 } = context;
184 switch (key) {
185 case 'Home':
186 return _extends({}, state, {
187 highlightedValue: moveHighlight(previouslySelectedValue, 'start', context)
188 });
189 case 'End':
190 return _extends({}, state, {
191 highlightedValue: moveHighlight(previouslySelectedValue, 'end', context)
192 });
193 case 'PageUp':
194 return _extends({}, state, {
195 highlightedValue: moveHighlight(previouslySelectedValue, -pageSize, context)
196 });
197 case 'PageDown':
198 return _extends({}, state, {
199 highlightedValue: moveHighlight(previouslySelectedValue, pageSize, context)
200 });
201 case 'ArrowUp':
202 if (orientation !== 'vertical') {
203 break;
204 }
205 return _extends({}, state, {
206 highlightedValue: moveHighlight(previouslySelectedValue, -1, context)
207 });
208 case 'ArrowDown':
209 if (orientation !== 'vertical') {
210 break;
211 }
212 return _extends({}, state, {
213 highlightedValue: moveHighlight(previouslySelectedValue, 1, context)
214 });
215 case 'ArrowLeft':
216 {
217 if (orientation === 'vertical') {
218 break;
219 }
220 const offset = orientation === 'horizontal-ltr' ? -1 : 1;
221 return _extends({}, state, {
222 highlightedValue: moveHighlight(previouslySelectedValue, offset, context)
223 });
224 }
225 case 'ArrowRight':
226 {
227 if (orientation === 'vertical') {
228 break;
229 }
230 const offset = orientation === 'horizontal-ltr' ? 1 : -1;
231 return _extends({}, state, {
232 highlightedValue: moveHighlight(previouslySelectedValue, offset, context)
233 });
234 }
235 case 'Enter':
236 case ' ':
237 if (state.highlightedValue === null) {
238 return state;
239 }
240 return handleItemSelection(state.highlightedValue, state, context);
241 default:
242 break;
243 }
244 return state;
245}
246function handleBlur(state, context) {
247 if (context.focusManagement === 'DOM') {
248 return state;
249 }
250 return _extends({}, state, {
251 highlightedValue: null
252 });
253}
254function textCriteriaMatches(nextFocus, searchString, stringifyItem) {
255 const text = stringifyItem(nextFocus)?.trim().toLowerCase();
256 if (!text || text.length === 0) {
257 // Make item not navigable if stringification fails or results in empty string.
258 return false;
259 }
260 return text.indexOf(searchString) === 0;
261}
262function handleTextNavigation(state, searchString, context) {
263 const {
264 items,
265 isItemDisabled,
266 disabledItemsFocusable,
267 getItemAsString
268 } = context;
269 const startWithCurrentItem = searchString.length > 1;
270 let nextItem = startWithCurrentItem ? state.highlightedValue : moveHighlight(state.highlightedValue, 1, context);
271 for (let index = 0; index < items.length; index += 1) {
272 // Return un-mutated state if looped back to the currently highlighted value
273 if (!nextItem || !startWithCurrentItem && state.highlightedValue === nextItem) {
274 return state;
275 }
276 if (textCriteriaMatches(nextItem, searchString, getItemAsString) && (!isItemDisabled(nextItem, items.indexOf(nextItem)) || disabledItemsFocusable)) {
277 // The nextItem is the element to be highlighted
278 return _extends({}, state, {
279 highlightedValue: nextItem
280 });
281 }
282 // Move to the next element.
283 nextItem = moveHighlight(nextItem, 1, context);
284 }
285
286 // No item matches the text search criteria
287 return state;
288}
289function handleItemsChange(items, previousItems, state, context) {
290 const {
291 itemComparer,
292 focusManagement
293 } = context;
294 let newHighlightedValue = null;
295 if (state.highlightedValue != null) {
296 newHighlightedValue = items.find(item => itemComparer(item, state.highlightedValue)) ?? null;
297 } else if (focusManagement === 'DOM' && previousItems.length === 0) {
298 newHighlightedValue = moveHighlight(null, 'reset', context);
299 }
300
301 // exclude selected values that are no longer in the items list
302 const selectedValues = state.selectedValues ?? [];
303 const newSelectedValues = selectedValues.filter(selectedValue => items.some(item => itemComparer(item, selectedValue)));
304 return _extends({}, state, {
305 highlightedValue: newHighlightedValue,
306 selectedValues: newSelectedValues
307 });
308}
309function handleResetHighlight(state, context) {
310 return _extends({}, state, {
311 highlightedValue: moveHighlight(null, 'reset', context)
312 });
313}
314function handleHighlightLast(state, context) {
315 return _extends({}, state, {
316 highlightedValue: moveHighlight(null, 'end', context)
317 });
318}
319function handleClearSelection(state, context) {
320 return _extends({}, state, {
321 selectedValues: [],
322 highlightedValue: moveHighlight(null, 'reset', context)
323 });
324}
325export function listReducer(state, action) {
326 const {
327 type,
328 context
329 } = action;
330 switch (type) {
331 case ListActionTypes.keyDown:
332 return handleKeyDown(action.key, state, context);
333 case ListActionTypes.itemClick:
334 return handleItemSelection(action.item, state, context);
335 case ListActionTypes.blur:
336 return handleBlur(state, context);
337 case ListActionTypes.textNavigation:
338 return handleTextNavigation(state, action.searchString, context);
339 case ListActionTypes.itemsChange:
340 return handleItemsChange(action.items, action.previousItems, state, context);
341 case ListActionTypes.resetHighlight:
342 return handleResetHighlight(state, context);
343 case ListActionTypes.highlightLast:
344 return handleHighlightLast(state, context);
345 case ListActionTypes.clearSelection:
346 return handleClearSelection(state, context);
347 default:
348 return state;
349 }
350}
\No newline at end of file