UNPKG

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