UNPKG

10.6 kBJavaScriptView Raw
1import _extends from "@babel/runtime/helpers/esm/extends";
2import * as React from 'react';
3import { unstable_useForkRef as useForkRef } from '@mui/utils';
4import { ListActionTypes } from './listActions.types';
5import defaultReducer from './listReducer';
6import useListChangeNotifiers from './useListChangeNotifiers';
7import useControllableReducer from '../utils/useControllableReducer';
8import areArraysEqual from '../utils/areArraysEqual';
9import useLatest from '../utils/useLatest';
10import useTextNavigation from '../utils/useTextNavigation';
11const EMPTY_OBJECT = {};
12const NOOP = () => {};
13const defaultItemComparer = (optionA, optionB) => optionA === optionB;
14const defaultIsItemDisabled = () => false;
15const defaultItemStringifier = item => typeof item === 'string' ? item : String(item);
16const defaultGetInitialState = () => ({
17 highlightedValue: null,
18 selectedValues: []
19});
20
21/**
22 * The useList is a lower-level utility that is used to build list-like components.
23 * It's used to manage the state of the list and its items.
24 *
25 * Supports highlighting a single item and selecting an arbitrary number of items.
26 *
27 * The state of the list is managed by a controllable reducer - that is a reducer that can have its state
28 * controlled from outside.
29 *
30 * By default, the state consists of `selectedValues` and `highlightedValue` but can be extended by the caller of the hook.
31 * Also the actions that can be dispatched and the reducer function can be defined externally.
32 *
33 * @template ItemValue The type of the item values.
34 * @template State The type of the list state. This should be a subtype of `ListState<ItemValue>`.
35 * @template CustomAction The type of the actions that can be dispatched (besides the standard ListAction).
36 * @template CustomActionContext The shape of additional properties that will be added to actions when dispatched.
37 *
38 * @ignore - internal hook.
39 */
40function useList(params) {
41 const {
42 controlledProps = EMPTY_OBJECT,
43 disabledItemsFocusable = false,
44 disableListWrap = false,
45 focusManagement = 'activeDescendant',
46 getInitialState = defaultGetInitialState,
47 getItemDomElement,
48 getItemId,
49 isItemDisabled = defaultIsItemDisabled,
50 rootRef: externalListRef,
51 onStateChange = NOOP,
52 items,
53 itemComparer = defaultItemComparer,
54 getItemAsString = defaultItemStringifier,
55 onChange,
56 onHighlightChange,
57 orientation = 'vertical',
58 pageSize = 5,
59 reducerActionContext = EMPTY_OBJECT,
60 selectionMode = 'single',
61 stateReducer: externalReducer
62 } = params;
63 if (process.env.NODE_ENV !== 'production') {
64 if (focusManagement === 'DOM' && getItemDomElement == null) {
65 throw new Error('useList: The `getItemDomElement` prop is required when using the `DOM` focus management.');
66 }
67 if (focusManagement === 'activeDescendant' && getItemId == null) {
68 throw new Error('useList: The `getItemId` prop is required when using the `activeDescendant` focus management.');
69 }
70 }
71 const listRef = React.useRef(null);
72 const handleRef = useForkRef(externalListRef, listRef);
73 const handleHighlightChange = React.useCallback((event, value, reason) => {
74 onHighlightChange == null ? void 0 : onHighlightChange(event, value, reason);
75 if (focusManagement === 'DOM' && value != null && (reason === ListActionTypes.itemClick || reason === ListActionTypes.keyDown || reason === ListActionTypes.textNavigation)) {
76 var _getItemDomElement;
77 getItemDomElement == null ? void 0 : (_getItemDomElement = getItemDomElement(value)) == null ? void 0 : _getItemDomElement.focus();
78 }
79 }, [getItemDomElement, onHighlightChange, focusManagement]);
80 const stateComparers = React.useMemo(() => ({
81 highlightedValue: itemComparer,
82 selectedValues: (valuesArray1, valuesArray2) => areArraysEqual(valuesArray1, valuesArray2, itemComparer)
83 }), [itemComparer]);
84
85 // This gets called whenever a reducer changes the state.
86 const handleStateChange = React.useCallback((event, field, value, reason, state) => {
87 onStateChange == null ? void 0 : onStateChange(event, field, value, reason, state);
88 switch (field) {
89 case 'highlightedValue':
90 handleHighlightChange(event, value, reason);
91 break;
92 case 'selectedValues':
93 onChange == null ? void 0 : onChange(event, value, reason);
94 break;
95 default:
96 break;
97 }
98 }, [handleHighlightChange, onChange, onStateChange]);
99
100 // The following object is added to each action when it's dispatched.
101 // It's accessible in the reducer via the `action.context` field.
102 const listActionContext = React.useMemo(() => {
103 return {
104 disabledItemsFocusable,
105 disableListWrap,
106 focusManagement,
107 isItemDisabled,
108 itemComparer,
109 items,
110 getItemAsString,
111 onHighlightChange: handleHighlightChange,
112 orientation,
113 pageSize,
114 selectionMode,
115 stateComparers
116 };
117 }, [disabledItemsFocusable, disableListWrap, focusManagement, isItemDisabled, itemComparer, items, getItemAsString, handleHighlightChange, orientation, pageSize, selectionMode, stateComparers]);
118 const initialState = getInitialState();
119 const reducer = externalReducer != null ? externalReducer : defaultReducer;
120 const actionContext = React.useMemo(() => _extends({}, reducerActionContext, listActionContext), [reducerActionContext, listActionContext]);
121 const [state, dispatch] = useControllableReducer({
122 reducer,
123 actionContext,
124 initialState: initialState,
125 controlledProps,
126 stateComparers,
127 onStateChange: handleStateChange
128 });
129 const {
130 highlightedValue,
131 selectedValues
132 } = state;
133 const handleTextNavigation = useTextNavigation((searchString, event) => dispatch({
134 type: ListActionTypes.textNavigation,
135 event,
136 searchString
137 }));
138
139 // introducing refs to avoid recreating the getItemState function on each change.
140 const latestSelectedValues = useLatest(selectedValues);
141 const latestHighlightedValue = useLatest(highlightedValue);
142 const previousItems = React.useRef([]);
143 React.useEffect(() => {
144 // Whenever the `items` object changes, we need to determine if the actual items changed.
145 // If they did, we need to dispatch an `itemsChange` action, so the selected/highlighted state is updated.
146 if (areArraysEqual(previousItems.current, items, itemComparer)) {
147 return;
148 }
149 dispatch({
150 type: ListActionTypes.itemsChange,
151 event: null,
152 items,
153 previousItems: previousItems.current
154 });
155 previousItems.current = items;
156 }, [items, itemComparer, dispatch]);
157
158 // Subitems are notified of changes to the highlighted and selected values.
159 // This is not done via context because we don't want to trigger a re-render of all the subitems each time any of them changes state.
160 // Instead, we use a custom message bus to publish messages about changes.
161 // On the child component, we use a custom hook to subscribe to these messages and re-render only when the value they care about changes.
162 const {
163 notifySelectionChanged,
164 notifyHighlightChanged,
165 registerHighlightChangeHandler,
166 registerSelectionChangeHandler
167 } = useListChangeNotifiers();
168 React.useEffect(() => {
169 notifySelectionChanged(selectedValues);
170 }, [selectedValues, notifySelectionChanged]);
171 React.useEffect(() => {
172 notifyHighlightChanged(highlightedValue);
173 }, [highlightedValue, notifyHighlightChanged]);
174 const createHandleKeyDown = other => event => {
175 var _other$onKeyDown;
176 (_other$onKeyDown = other.onKeyDown) == null ? void 0 : _other$onKeyDown.call(other, event);
177 if (event.defaultMuiPrevented) {
178 return;
179 }
180 const keysToPreventDefault = ['Home', 'End', 'PageUp', 'PageDown'];
181 if (orientation === 'vertical') {
182 keysToPreventDefault.push('ArrowUp', 'ArrowDown');
183 } else {
184 keysToPreventDefault.push('ArrowLeft', 'ArrowRight');
185 }
186 if (focusManagement === 'activeDescendant') {
187 // When the child element is focused using the activeDescendant attribute,
188 // the list handles keyboard events on its behalf.
189 // We have to `preventDefault()` is this case to prevent the browser from
190 // scrolling the view when space is pressed or submitting forms when enter is pressed.
191 keysToPreventDefault.push(' ', 'Enter');
192 }
193 if (keysToPreventDefault.includes(event.key)) {
194 event.preventDefault();
195 }
196 dispatch({
197 type: ListActionTypes.keyDown,
198 key: event.key,
199 event
200 });
201 handleTextNavigation(event);
202 };
203 const createHandleBlur = other => event => {
204 var _other$onBlur, _listRef$current;
205 (_other$onBlur = other.onBlur) == null ? void 0 : _other$onBlur.call(other, event);
206 if (event.defaultMuiPrevented) {
207 return;
208 }
209 if ((_listRef$current = listRef.current) != null && _listRef$current.contains(event.relatedTarget)) {
210 // focus remains within the list
211 return;
212 }
213 dispatch({
214 type: ListActionTypes.blur,
215 event
216 });
217 };
218 const getRootProps = (otherHandlers = {}) => {
219 return _extends({}, otherHandlers, {
220 'aria-activedescendant': focusManagement === 'activeDescendant' && highlightedValue != null ? getItemId(highlightedValue) : undefined,
221 onBlur: createHandleBlur(otherHandlers),
222 onKeyDown: createHandleKeyDown(otherHandlers),
223 tabIndex: focusManagement === 'DOM' ? -1 : 0,
224 ref: handleRef
225 });
226 };
227 const getItemState = React.useCallback(item => {
228 var _latestSelectedValues;
229 const index = items.findIndex(i => itemComparer(i, item));
230 const selected = ((_latestSelectedValues = latestSelectedValues.current) != null ? _latestSelectedValues : []).some(value => value != null && itemComparer(item, value));
231 const disabled = isItemDisabled(item, index);
232 const highlighted = latestHighlightedValue.current != null && itemComparer(item, latestHighlightedValue.current);
233 const focusable = focusManagement === 'DOM';
234 return {
235 disabled,
236 focusable,
237 highlighted,
238 index,
239 selected
240 };
241 }, [items, isItemDisabled, itemComparer, latestSelectedValues, latestHighlightedValue, focusManagement]);
242 const contextValue = React.useMemo(() => ({
243 dispatch,
244 getItemState,
245 registerHighlightChangeHandler,
246 registerSelectionChangeHandler
247 }), [dispatch, getItemState, registerHighlightChangeHandler, registerSelectionChangeHandler]);
248 React.useDebugValue({
249 state
250 });
251 return {
252 contextValue,
253 dispatch,
254 getRootProps,
255 rootRef: handleRef,
256 state
257 };
258}
259export default useList;
\No newline at end of file