UNPKG

8.9 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?.(event, value, reason);
78 if (focusManagement === 'DOM' && value != null && (reason === ListActionTypes.itemClick || reason === ListActionTypes.keyDown || reason === ListActionTypes.textNavigation)) {
79 getItemDomElement?.(value)?.focus();
80 }
81 }, [getItemDomElement, onHighlightChange, focusManagement]);
82 const stateComparers = React.useMemo(() => ({
83 highlightedValue: itemComparer,
84 selectedValues: (valuesArray1, valuesArray2) => areArraysEqual(valuesArray1, valuesArray2, itemComparer)
85 }), [itemComparer]);
86
87 // This gets called whenever a reducer changes the state.
88 const handleStateChange = React.useCallback((event, field, value, reason, state) => {
89 onStateChange?.(event, field, value, reason, state);
90 switch (field) {
91 case 'highlightedValue':
92 handleHighlightChange(event, value, reason);
93 break;
94 case 'selectedValues':
95 onChange?.(event, value, reason);
96 break;
97 default:
98 break;
99 }
100 }, [handleHighlightChange, onChange, onStateChange]);
101
102 // The following object is added to each action when it's dispatched.
103 // It's accessible in the reducer via the `action.context` field.
104 const listActionContext = React.useMemo(() => {
105 return {
106 disabledItemsFocusable,
107 disableListWrap,
108 focusManagement,
109 isItemDisabled,
110 itemComparer,
111 items,
112 getItemAsString,
113 onHighlightChange: handleHighlightChange,
114 orientation,
115 pageSize,
116 selectionMode,
117 stateComparers
118 };
119 }, [disabledItemsFocusable, disableListWrap, focusManagement, isItemDisabled, itemComparer, items, getItemAsString, handleHighlightChange, orientation, pageSize, selectionMode, stateComparers]);
120 const initialState = getInitialState();
121 const reducer = externalReducer ?? defaultReducer;
122 const actionContext = React.useMemo(() => _extends({}, reducerActionContext, listActionContext), [reducerActionContext, listActionContext]);
123 const [state, dispatch] = useControllableReducer({
124 reducer,
125 actionContext,
126 initialState: initialState,
127 controlledProps,
128 stateComparers,
129 onStateChange: handleStateChange,
130 componentName
131 });
132 const {
133 highlightedValue,
134 selectedValues
135 } = state;
136 const handleTextNavigation = useTextNavigation((searchString, event) => dispatch({
137 type: ListActionTypes.textNavigation,
138 event,
139 searchString
140 }));
141 const previousItems = React.useRef([]);
142 React.useEffect(() => {
143 // Whenever the `items` object changes, we need to determine if the actual items changed.
144 // If they did, we need to dispatch an `itemsChange` action, so the selected/highlighted state is updated.
145 if (areArraysEqual(previousItems.current, items, itemComparer)) {
146 return;
147 }
148 dispatch({
149 type: ListActionTypes.itemsChange,
150 event: null,
151 items,
152 previousItems: previousItems.current
153 });
154 previousItems.current = items;
155 onItemsChange?.(items);
156 }, [items, itemComparer, dispatch, onItemsChange]);
157 const createHandleKeyDown = externalHandlers => event => {
158 externalHandlers.onKeyDown?.(event);
159 if (event.defaultMuiPrevented) {
160 return;
161 }
162 const keysToPreventDefault = ['Home', 'End', 'PageUp', 'PageDown'];
163 if (orientation === 'vertical') {
164 keysToPreventDefault.push('ArrowUp', 'ArrowDown');
165 } else {
166 keysToPreventDefault.push('ArrowLeft', 'ArrowRight');
167 }
168 if (focusManagement === 'activeDescendant') {
169 // When the child element is focused using the activeDescendant attribute,
170 // the list handles keyboard events on its behalf.
171 // We have to `preventDefault()` is this case to prevent the browser from
172 // scrolling the view when space is pressed or submitting forms when enter is pressed.
173 keysToPreventDefault.push(' ', 'Enter');
174 }
175 if (keysToPreventDefault.includes(event.key)) {
176 event.preventDefault();
177 }
178 dispatch({
179 type: ListActionTypes.keyDown,
180 key: event.key,
181 event
182 });
183 handleTextNavigation(event);
184 };
185 const createHandleBlur = externalHandlers => event => {
186 externalHandlers.onBlur?.(event);
187 if (event.defaultMuiPrevented) {
188 return;
189 }
190 if (listRef.current?.contains(event.relatedTarget)) {
191 // focus remains within the list
192 return;
193 }
194 dispatch({
195 type: ListActionTypes.blur,
196 event
197 });
198 };
199 const getRootProps = (externalProps = {}) => {
200 const externalEventHandlers = extractEventHandlers(externalProps);
201 return _extends({}, externalProps, {
202 'aria-activedescendant': focusManagement === 'activeDescendant' && highlightedValue != null ? getItemId(highlightedValue) : undefined,
203 tabIndex: focusManagement === 'DOM' ? -1 : 0,
204 ref: handleRef
205 }, externalEventHandlers, {
206 onBlur: createHandleBlur(externalEventHandlers),
207 onKeyDown: createHandleKeyDown(externalEventHandlers)
208 });
209 };
210 const getItemState = React.useCallback(item => {
211 const selected = (selectedValues ?? []).some(value => value != null && itemComparer(item, value));
212 const highlighted = highlightedValue != null && itemComparer(item, highlightedValue);
213 const focusable = focusManagement === 'DOM';
214 return {
215 focusable,
216 highlighted,
217 selected
218 };
219 }, [itemComparer, selectedValues, highlightedValue, focusManagement]);
220 const contextValue = React.useMemo(() => ({
221 dispatch,
222 getItemState
223 }), [dispatch, getItemState]);
224 React.useDebugValue({
225 state
226 });
227 return {
228 contextValue,
229 dispatch,
230 getRootProps,
231 rootRef: handleRef,
232 state
233 };
234}
235export { useList };
\No newline at end of file