Skip to content

Commit

Permalink
Merge pull request #6172 from gucal/master
Browse files Browse the repository at this point in the history
Listbox - focusOnHover prop
  • Loading branch information
gucal authored Mar 20, 2024
2 parents ddc9ae8 + ba2b4ce commit 5358503
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 21 deletions.
98 changes: 88 additions & 10 deletions components/lib/listbox/ListBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export const ListBox = React.memo(
const firstHiddenFocusableElement = React.useRef(null);
const lastHiddenFocusableElement = React.useRef(null);
const [startRangeIndex, setStartRangeIndex] = React.useState(-1);
const [focused, setFocused] = React.useState(false);
const [filterValueState, setFilterValueState] = React.useState('');
const [searchValue, setSearchValue] = React.useState('');
const elementRef = React.useRef(null);
const virtualScrollerRef = React.useRef(null);
const id = React.useRef(null);
Expand Down Expand Up @@ -49,6 +51,16 @@ export const ListBox = React.memo(
index !== -1 && setFocusedOptionIndex(index);
};

const onOptionMouseDown = (event, index) => {
changeFocusedOptionIndex(event, index);
};

const onOptionMouseMove = (event, index) => {
if (props.focusOnHover && focused) {
changeFocusedOptionIndex(event, index);
}
};

const onOptionTouchEnd = () => {
if (props.disabled) {
return;
Expand Down Expand Up @@ -150,6 +162,31 @@ export const ListBox = React.memo(
return visibleOptions.findIndex((option) => isValidOption(option));
};

const findLastSelectedOptionIndex = () => {
return hasSelectedOption() ? ObjectUtils.findLastIndex(visibleOptions, (option) => isValidSelectedOption(option)) : -1;
};

const findSelectedOptionIndex = () => {
if (hasSelectedOption()) {
if (props.multiple) {
for (let index = props.value.length - 1; index >= 0; index--) {
const value = props.value[index];
const matchedOptionIndex = visibleOptions.findIndex((option) => isValidSelectedOption(option) && isEquals(value, getOptionValue(option)));

if (matchedOptionIndex > -1) return matchedOptionIndex;
}
} else {
return visibleOptions.findIndex((option) => isValidSelectedOption(option));
}
}

return -1;
};

const findFirstSelectedOptionIndex = () => {
return hasSelectedOption() ? visibleOptions.findIndex((option) => isValidSelectedOption(option)) : -1;
};

const findLastOptionIndex = () => {
return ObjectUtils.findLastIndex(visibleOptions, (option) => isValidOption(option));
};
Expand All @@ -173,7 +210,7 @@ export const ListBox = React.memo(
const findNearestSelectedOptionIndex = (index, firstCheckUp = false) => {
let matchedOptionIndex = -1;

if (hasSelectedOption) {
if (hasSelectedOption()) {
if (firstCheckUp) {
matchedOptionIndex = findPrevSelectedOptionIndex(index);
matchedOptionIndex = matchedOptionIndex === -1 ? findNextSelectedOptionIndex(index) : matchedOptionIndex;
Expand All @@ -191,7 +228,7 @@ export const ListBox = React.memo(
};

const searchOptions = (event, char) => {
searchValue = (searchValue || '') + char;
setSearchValue((searchValue || '') + char);

let optionIndex = -1;

Expand All @@ -217,19 +254,19 @@ export const ListBox = React.memo(
}

searchTimeout.current = setTimeout(() => {
searchValue = '';
setSearchValue('');
searchTimeout.current = null;
}, 500);
};

const findNextSelectedOptionIndex = (index) => {
const matchedOptionIndex = hasSelectedOption && index < visibleOptions.length - 1 ? visibleOptions.slice(index + 1).findIndex((option) => isValidSelectedOption(option)) : -1;
const matchedOptionIndex = hasSelectedOption() && index < visibleOptions.length - 1 ? visibleOptions.slice(index + 1).findIndex((option) => isValidSelectedOption(option)) : -1;

return matchedOptionIndex > -1 ? matchedOptionIndex + index + 1 : -1;
};

const findPrevSelectedOptionIndex = (index) => {
const matchedOptionIndex = hasSelectedOption && index > 0 ? ObjectUtils.findLastIndex(visibleOptions.slice(0, index), (option) => isValidSelectedOption(option)) : -1;
const matchedOptionIndex = hasSelectedOption() && index > 0 ? ObjectUtils.findLastIndex(visibleOptions.slice(0, index), (option) => isValidSelectedOption(option)) : -1;

return matchedOptionIndex > -1 ? matchedOptionIndex : -1;
};
Expand All @@ -256,6 +293,12 @@ export const ListBox = React.memo(
return selectedIndex < 0 ? findFirstOptionIndex() : selectedIndex;
};

const findLastFocusedOptionIndex = () => {
const selectedIndex = findLastSelectedOptionIndex();

return selectedIndex < 0 ? findLastOptionIndex() : selectedIndex;
};

const changeFocusedOptionIndex = (event, index) => {
if (focusedOptionIndex !== index) {
setFocusedOptionIndex(index);
Expand Down Expand Up @@ -355,7 +398,7 @@ export const ListBox = React.memo(
event.preventDefault();
};

const onKeyDown = (event) => {
const onListKeyDown = (event) => {
const metaKey = event.metaKey || event.ctrlKey;

switch (event.code) {
Expand Down Expand Up @@ -426,7 +469,7 @@ export const ListBox = React.memo(
if (element) {
element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
} else if (props.virtualScrollerOptions) {
virtualScrollerRef.current && virtualScrollerRef.current.scrollToIndex(index !== -1 ? index : props.focusedOptionIndex);
virtualScrollerRef.current && virtualScrollerRef.current.scrollToIndex(index !== -1 ? index : focusedOptionIndex);
}
}, 0);
};
Expand All @@ -451,6 +494,15 @@ export const ListBox = React.memo(
props.onFilter && props.onFilter({ filter: '' });
};

const autoUpdateModel = (isFocus = focused) => {
if (props.selectOnFocus && props.autoOptionFocus && !hasSelectedOption() && !props.multiple && isFocus) {
const currentFocusOptionIndex = findFirstFocusedOptionIndex();

onOptionSelect(null, visibleOptions[currentFocusOptionIndex]);
setFocusedOptionIndex(currentFocusOptionIndex);
}
};

const updateModel = (event, value) => {
if (props.onChange) {
props.onChange({
Expand Down Expand Up @@ -503,11 +555,15 @@ export const ListBox = React.memo(
return list.findIndex((item) => ObjectUtils.equals(value, getOptionValue(item), key));
};

const isEquals = (value1, value2) => {
return ObjectUtils.equals(value1, value2, equalityKey());
};

const isSelected = (option) => {
const optionValue = getOptionValue(option);
const key = equalityKey();

return props.multiple && props.value ? props.value.some((val) => ObjectUtils.equals(val, optionValue, key)) : ObjectUtils.equals(props.value, optionValue, key);
if (props.multiple) return (props.value || []).some((value) => isEquals(value, optionValue));
else return isEquals(props.value, optionValue);
};

const filter = (option) => {
Expand Down Expand Up @@ -562,6 +618,19 @@ export const ListBox = React.memo(
lastHiddenFocusableElement.current.tabIndex = -1;
};

const onListFocus = () => {
setFocused(true);
setFocusedOptionIndex(focusedOptionIndex !== -1 ? focusedOptionIndex : props.autoOptionFocus ? findFirstFocusedOptionIndex() : findSelectedOptionIndex());
autoUpdateModel(true);
};

const onListBlur = (event) => {
setFocused(false);
setFocusedOptionIndex(-1);
setStartRangeIndex(-1);
setSearchValue('');
};

const getOptionGroupRenderKey = (optionGroup) => {
return ObjectUtils.resolveFieldData(optionGroup, props.optionGroupLabel);
};
Expand Down Expand Up @@ -658,6 +727,8 @@ export const ListBox = React.memo(
style={style}
template={props.itemTemplate}
selected={isSelected(option)}
onOptionMouseDown={onOptionMouseDown}
onOptionMouseMove={onOptionMouseMove}
onClick={onOptionSelect}
index={j}
focusedOptionIndex={focusedOptionIndex}
Expand Down Expand Up @@ -706,6 +777,8 @@ export const ListBox = React.memo(
key={optionKey}
label={optionLabel}
index={index}
onOptionMouseDown={onOptionMouseDown}
onOptionMouseMove={onOptionMouseMove}
focusedOptionIndex={focusedOptionIndex}
option={option}
style={style}
Expand Down Expand Up @@ -761,6 +834,9 @@ export const ListBox = React.memo(
role: 'listbox',
tabIndex: '-1',
'aria-multiselectable': props.multiple,
onFocus: onListFocus,
onBlur: onListBlur,
onKeyDown: onListKeyDown,
...ariaProps
},
ptCallbacks.ptm('list')
Expand All @@ -782,7 +858,9 @@ export const ListBox = React.memo(
role: 'listbox',
'aria-multiselectable': props.multiple,
tabIndex: '-1',
onKeyDown: onKeyDown,
onFocus: onListFocus,
onBlur: onListBlur,
onKeyDown: onListKeyDown,
...ariaProps
},
ptCallbacks.ptm('list')
Expand Down
2 changes: 1 addition & 1 deletion components/lib/listbox/ListBoxBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const ListBoxBase = ComponentBase.extend({
filterPlaceholder: null,
filterTemplate: null,
filterValue: null,
selectOnFocus: false,
focusOnHover: true,
id: null,
itemTemplate: null,
invalid: false,
Expand Down
2 changes: 2 additions & 0 deletions components/lib/listbox/ListBoxItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export const ListBoxItem = React.memo((props) => {
onFocus: onFocus,
onBlur: onBlur,
tabIndex: '-1',
onMouseDown: (event) => props.onOptionMouseDown(event, props.index),
onMouseMove: (event) => props.onOptionMouseMove(event, props.index),
'aria-label': props.label,
key: props.optionKey,
role: 'option',
Expand Down
25 changes: 15 additions & 10 deletions components/lib/listbox/listbox.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,16 +339,6 @@ export interface ListBoxProps extends Omit<React.DetailedHTMLProps<React.InputHT
* @defaultValue true
*/
metaKeySelection?: boolean | undefined;
/**
* When enabled, the focused tab is activated.
* @defaultValue false
*/
selectOnFocus?: false;
/**
* Whether to focus on the first visible or selected element.
* @defaultValue false
*/
autoOptionFocus?: false;
/**
* When specified, allows selecting multiple values.
* @defaultValue false
Expand Down Expand Up @@ -399,6 +389,21 @@ export interface ListBoxProps extends Omit<React.DetailedHTMLProps<React.InputHT
* @type {VirtualScrollerProps}
*/
virtualScrollerOptions?: VirtualScrollerProps | undefined;
/**
* Whether to focus on the first visible or selected element.
* @defaultValue false
*/
autoOptionFocus?: boolean | undefined;
/**
* When enabled, the focused option is selected.
* @defaultValue false
*/
selectOnFocus?: boolean | undefined;
/**
* When enabled, the focus is placed on the hovered option.
* @defaultValue true
*/
focusOnHover?: boolean | undefined;
/**
* Uses to pass attributes to DOM elements inside the component.
* @type {ListboxPassThroughOptions}
Expand Down

0 comments on commit 5358503

Please sign in to comment.