194 lines
7.4 KiB
JavaScript
194 lines
7.4 KiB
JavaScript
import * as React from 'react';
|
|
import * as ReactDOM from 'react-dom';
|
|
import { useControllableState, useEventCallback, useFirstMount } from '@fluentui/react-utilities';
|
|
import { useOptionCollection } from '../utils/useOptionCollection';
|
|
import { useSelection } from '../utils/useSelection';
|
|
/**
|
|
* @internal
|
|
* State shared between Combobox and Dropdown components
|
|
*/ export const useComboboxBaseState = (props)=>{
|
|
'use no memo';
|
|
const { appearance = 'outline', disableAutoFocus, children, clearable = false, editable = false, inlinePopup = false, mountNode = undefined, multiselect, onOpenChange, size = 'medium', activeDescendantController, freeform = false, disabled = false, onActiveOptionChange = null } = props;
|
|
const optionCollection = useOptionCollection();
|
|
const { getOptionsMatchingValue } = optionCollection;
|
|
const { getOptionById } = optionCollection;
|
|
const getActiveOption = React.useCallback(()=>{
|
|
const activeOptionId = activeDescendantController.active();
|
|
return activeOptionId ? getOptionById(activeOptionId) : undefined;
|
|
}, [
|
|
activeDescendantController,
|
|
getOptionById
|
|
]);
|
|
// Keeping some kind of backwards compatible functionality here
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
const UNSAFE_activeOption = getActiveOption();
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
const UNSAFE_setActiveOption = React.useCallback((option)=>{
|
|
let nextOption = undefined;
|
|
if (typeof option === 'function') {
|
|
const activeOption = getActiveOption();
|
|
nextOption = option(activeOption);
|
|
}
|
|
if (nextOption) {
|
|
activeDescendantController.focus(nextOption.id);
|
|
} else {
|
|
activeDescendantController.blur();
|
|
}
|
|
}, [
|
|
activeDescendantController,
|
|
getActiveOption
|
|
]);
|
|
// track whether keyboard focus outline should be shown
|
|
// tabster/keyborg doesn't work here, since the actual keyboard focus target doesn't move
|
|
const [focusVisible, setFocusVisible] = React.useState(false);
|
|
// track focused state to conditionally render collapsed listbox
|
|
// when the trigger is focused - the listbox should but hidden until the open state is changed
|
|
const [hasFocus, setHasFocus] = React.useState(false);
|
|
const ignoreNextBlur = React.useRef(false);
|
|
// calculate value based on props, internal value changes, and selected options
|
|
const isFirstMount = useFirstMount();
|
|
const [controllableValue, setValue] = useControllableState({
|
|
state: props.value,
|
|
initialState: undefined
|
|
});
|
|
const { selectedOptions, selectOption: baseSelectOption, clearSelection } = useSelection(props);
|
|
// reset any typed value when an option is selected
|
|
const selectOption = React.useCallback((ev, option)=>{
|
|
ReactDOM.unstable_batchedUpdates(()=>{
|
|
setValue(undefined);
|
|
baseSelectOption(ev, option);
|
|
});
|
|
}, [
|
|
setValue,
|
|
baseSelectOption
|
|
]);
|
|
const value = React.useMemo(()=>{
|
|
// don't compute the value if it is defined through props or setValue,
|
|
if (controllableValue !== undefined) {
|
|
return controllableValue;
|
|
}
|
|
// handle defaultValue here, so it is overridden by selection
|
|
if (isFirstMount && props.defaultValue !== undefined) {
|
|
return props.defaultValue;
|
|
}
|
|
const selectedOptionsText = getOptionsMatchingValue((optionValue)=>{
|
|
return selectedOptions.includes(optionValue);
|
|
}).map((option)=>option.text);
|
|
if (multiselect) {
|
|
// editable inputs should not display multiple selected options in the input as text
|
|
return editable ? '' : selectedOptionsText.join(', ');
|
|
}
|
|
return selectedOptionsText[0];
|
|
// do not change value after isFirstMount changes,
|
|
// we do not want to accidentally override defaultValue on a second render
|
|
// unless another value is intentionally set
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
controllableValue,
|
|
editable,
|
|
getOptionsMatchingValue,
|
|
multiselect,
|
|
props.defaultValue,
|
|
selectedOptions
|
|
]);
|
|
// Handle open state, which is shared with options in context
|
|
const [open, setOpenState] = useControllableState({
|
|
state: props.open,
|
|
defaultState: props.defaultOpen,
|
|
initialState: false
|
|
});
|
|
const setOpen = React.useCallback((event, newState)=>{
|
|
if (disabled) {
|
|
return;
|
|
}
|
|
onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(event, {
|
|
open: newState
|
|
});
|
|
ReactDOM.unstable_batchedUpdates(()=>{
|
|
if (!newState && !freeform) {
|
|
setValue(undefined);
|
|
}
|
|
setOpenState(newState);
|
|
});
|
|
}, [
|
|
onOpenChange,
|
|
setOpenState,
|
|
setValue,
|
|
freeform,
|
|
disabled
|
|
]);
|
|
// update active option based on change in open state
|
|
React.useEffect(()=>{
|
|
if (open) {
|
|
// if it is single-select and there is a selected option, start at the selected option
|
|
if (!multiselect && selectedOptions.length > 0) {
|
|
const selectedOption = getOptionsMatchingValue((v)=>v === selectedOptions[0]).pop();
|
|
if (selectedOption === null || selectedOption === void 0 ? void 0 : selectedOption.id) {
|
|
activeDescendantController.focus(selectedOption.id);
|
|
}
|
|
}
|
|
} else {
|
|
activeDescendantController.blur();
|
|
}
|
|
// this should only be run in response to changes in the open state
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
open,
|
|
activeDescendantController
|
|
]);
|
|
// Fallback focus when children are updated in an open popover results in no item being focused
|
|
React.useEffect(()=>{
|
|
if (open && !disableAutoFocus && !activeDescendantController.active()) {
|
|
activeDescendantController.first();
|
|
}
|
|
// this should only be run in response to changes in the open state or children
|
|
}, [
|
|
open,
|
|
children,
|
|
disableAutoFocus,
|
|
activeDescendantController,
|
|
getOptionById
|
|
]);
|
|
const onActiveDescendantChange = useEventCallback((event)=>{
|
|
const previousOption = event.detail.previousId ? optionCollection.getOptionById(event.detail.previousId) : null;
|
|
const nextOption = optionCollection.getOptionById(event.detail.id);
|
|
onActiveOptionChange === null || onActiveOptionChange === void 0 ? void 0 : onActiveOptionChange(event, {
|
|
event,
|
|
type: 'change',
|
|
previousOption,
|
|
nextOption
|
|
});
|
|
});
|
|
return {
|
|
...optionCollection,
|
|
freeform,
|
|
disabled,
|
|
selectOption,
|
|
clearSelection,
|
|
selectedOptions,
|
|
activeOption: UNSAFE_activeOption,
|
|
appearance,
|
|
clearable,
|
|
focusVisible,
|
|
ignoreNextBlur,
|
|
inlinePopup,
|
|
mountNode,
|
|
open,
|
|
hasFocus,
|
|
setActiveOption: UNSAFE_setActiveOption,
|
|
setFocusVisible,
|
|
setHasFocus,
|
|
setOpen,
|
|
setValue,
|
|
size,
|
|
value,
|
|
multiselect,
|
|
onOptionClick: useEventCallback((e)=>{
|
|
if (!multiselect) {
|
|
setOpen(e, false);
|
|
}
|
|
}),
|
|
onActiveDescendantChange
|
|
};
|
|
};
|