@dnb/eufemia
Version:
DNB Eufemia Design System UI Library
511 lines (510 loc) • 20.6 kB
JavaScript
"use client";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import * as z from 'zod';
import clsx from 'clsx';
import { AriaLive, Popover } from "../../../../components/index.js";
import FieldBlock from "../../FieldBlock/index.js";
import { useFieldProps } from "../../hooks/index.js";
import { pickSpacingProps } from "../../../../components/flex/utils.js";
import DataContext from "../../DataContext/Context.js";
import useDataValue from "../../hooks/useDataValue.js";
import useTranslation from "../../hooks/useTranslation.js";
import { convertJsxToString } from "../../../../shared/component-helper.js";
import whatInput from "../../../../shared/helpers/whatInput.js";
import useIsomorphicLayoutEffect from "../../../../shared/helpers/useIsomorphicLayoutEffect.js";
import withComponentMarkers from "../../../../shared/helpers/withComponentMarkers.js";
import { createSharedState } from "../../../../shared/helpers/useSharedState.js";
import { MultiSelectionTrigger } from "./MultiSelectionTrigger.js";
import { MultiSelectionSearch } from "./MultiSelectionSearch.js";
import { MultiSelectionSelectedTags } from "./MultiSelectionSelectedTags.js";
import { MultiSelectionItemList } from "./MultiSelectionItemList.js";
import { MultiSelectionActions } from "./MultiSelectionActions.js";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
function MultiSelection(props) {
const {
id,
path,
dataPath,
data,
className,
variant = 'popover',
width,
showSearchField = false,
showSelectedTags = false,
showConfirmButton = false,
showSelectAll = false,
selectedItemsCollapsibleThreshold = 10,
value,
disabled,
emptyValue,
htmlAttributes,
handleChange,
setDisplayValue
} = useFieldProps({
...props,
schema: (() => {
if (typeof props.minItems === 'number' || typeof props.maxItems === 'number') {
return p => {
let s = z.array(z.union([z.string(), z.number()]));
if (typeof p.minItems === 'number') {
var _p$errorMessages$minI;
s = s.min(p.minItems, {
message: (_p$errorMessages$minI = p.errorMessages?.minItems) !== null && _p$errorMessages$minI !== void 0 ? _p$errorMessages$minI : 'MultiSelectionField.errorMinItems'
});
}
if (typeof p.maxItems === 'number') {
var _p$errorMessages$maxI;
s = s.max(p.maxItems, {
message: (_p$errorMessages$maxI = p.errorMessages?.maxItems) !== null && _p$errorMessages$maxI !== void 0 ? _p$errorMessages$maxI : 'MultiSelectionField.errorMaxItems'
});
}
return s;
};
}
return props.schema;
})()
});
const {
MultiSelectionField: translation,
formatMessage
} = useTranslation();
const formatSelectionCount = useCallback((count, total) => formatMessage(translation.selectionCount, {
count,
total
}), [formatMessage, translation.selectionCount]);
const {
getValueByPath
} = useDataValue();
const {
setFieldInternals
} = useContext(DataContext);
const dataList = dataPath ? getValueByPath(dataPath) : data;
const [isOpen, setIsOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [tempValue, setTempValue] = useState(value || []);
const [ariaLiveCheckedCount, setAriaLiveCheckedCount] = useState('');
const confirmedRef = useRef(false);
const isInline = variant === 'inline';
useEffect(() => {
if (!isOpen || isInline) {
setTempValue(value || []);
}
}, [value, isOpen, isInline]);
const popoverContentRef = useRef(null);
const triggerRef = useRef(null);
const handlePopoverContentRef = useCallback(el => {
popoverContentRef.current = el;
if (!el || !isOpen) {
return;
}
requestAnimationFrame(() => {
var _triggerRef$current;
const trigger = (_triggerRef$current = triggerRef.current) !== null && _triggerRef$current !== void 0 ? _triggerRef$current : document.getElementById(id);
triggerRef.current = trigger;
const contentRect = el.getBoundingClientRect();
const triggerRect = trigger?.getBoundingClientRect();
const margin = 16;
const isBottomPlacement = !triggerRect || contentRect.top >= triggerRect.bottom;
const maxHeight = isBottomPlacement ? window.innerHeight - contentRect.top - margin : contentRect.bottom - margin;
if (maxHeight > 100) {
el.style.setProperty('--popover-max-height', `${Math.max(0, maxHeight)}px`);
}
});
}, [id, isOpen]);
const pendingTriggerNavigationRef = useRef(null);
const pendingCheckedCountAnnouncementRef = useRef(false);
const previousTempValueRef = useRef(value || []);
const hasFeature = showSearchField || showSelectedTags || showConfirmButton;
const toSearchText = useCallback(content => {
return convertJsxToString(content || '').toLowerCase();
}, []);
useIsomorphicLayoutEffect(() => {
if (isOpen) {
whatInput.specificKeys(['Tab', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'PageUp', 'PageDown', 'End', 'Home']);
}
return () => {
whatInput.specificKeys(['Tab']);
};
}, [isOpen]);
const flattenItems = useCallback(items => {
if (!items) {
return [];
}
return items.flatMap(item => [item, ...(item.children ? flattenItems(item.children) : [])]);
}, []);
const allFlatItems = useMemo(() => flattenItems(dataList), [dataList, flattenItems]);
const filteredItems = useMemo(() => {
if (!dataList) {
return [];
}
if (!searchValue) {
return dataList;
}
const searchLower = searchValue.toLowerCase();
const filterRecursive = items => {
return items.map(item => {
const title = convertJsxToString(item.title).toLowerCase();
const text = toSearchText(item.text);
const description = toSearchText(item.description);
const matches = title.includes(searchLower) || text.includes(searchLower) || description.includes(searchLower);
const children = item.children ? filterRecursive(item.children) : [];
if (matches || children.length > 0) {
return {
...item,
children: children.length > 0 ? children : item.children
};
}
return null;
}).filter(Boolean);
};
return filterRecursive(dataList);
}, [dataList, searchValue, toSearchText]);
const selectedItems = useMemo(() => {
if (!tempValue) {
return [];
}
return allFlatItems.filter(item => tempValue.includes(item.value));
}, [allFlatItems, tempValue]);
const totalCount = allFlatItems.length;
const selectedCount = selectedItems.length;
const isCollapsible = totalCount > selectedItemsCollapsibleThreshold;
const confirmedItems = useMemo(() => {
if (!value) {
return [];
}
return allFlatItems.filter(item => value.includes(item.value));
}, [allFlatItems, value]);
const displayCount = showConfirmButton ? confirmedItems.length : selectedCount;
useEffect(() => {
const previousValue = previousTempValueRef.current;
const hasChanged = previousValue.length !== tempValue.length || previousValue.some((item, index) => item !== tempValue[index]);
if (!hasChanged) {
previousTempValueRef.current = tempValue;
return;
}
previousTempValueRef.current = tempValue;
if (!pendingCheckedCountAnnouncementRef.current) {
return;
}
pendingCheckedCountAnnouncementRef.current = false;
setAriaLiveCheckedCount(formatSelectionCount(tempValue.length, totalCount));
}, [tempValue, totalCount, formatSelectionCount]);
const getParentState = useCallback(item => {
if (!item.children || item.children.length === 0) {
return {
checked: tempValue.includes(item.value),
indeterminate: false
};
}
const children = flattenItems(item.children);
const checkedChildren = children.filter(child => tempValue.includes(child.value)).length;
return {
checked: checkedChildren === children.length,
indeterminate: checkedChildren > 0 && checkedChildren < children.length
};
}, [tempValue, flattenItems]);
const normalizeValue = useCallback(nextValue => {
const normalized = new Set(nextValue);
normalized.forEach(itemValue => {
const item = allFlatItems.find(i => i.value === itemValue);
if (item?.children) {
const childValues = flattenItems(item.children).map(c => c.value);
const allChildrenInValue = childValues.every(childVal => normalized.has(childVal));
if (!allChildrenInValue) {
normalized.delete(itemValue);
}
}
});
const parentItems = allFlatItems.filter(item => item.children);
parentItems.forEach(item => {
const childValues = flattenItems(item.children).map(c => c.value);
const allChildrenInValue = childValues.every(childVal => normalized.has(childVal));
if (allChildrenInValue && childValues.length > 0) {
normalized.add(item.value);
}
});
return Array.from(normalized);
}, [allFlatItems, flattenItems]);
const applyChange = useCallback(nextValue => {
const normalizedValue = normalizeValue(nextValue);
const finalValue = normalizedValue.length === 0 ? emptyValue : normalizedValue;
handleChange?.(finalValue);
const nextSelectedItems = allFlatItems.filter(item => normalizedValue.includes(item.value));
setDisplayValue(nextSelectedItems.map(item => item.title));
if (path) {
setFieldInternals?.(path + '/multiSelectionData', {
props: nextSelectedItems
});
}
}, [allFlatItems, emptyValue, handleChange, setDisplayValue, path, setFieldInternals, normalizeValue]);
const handleToggleItem = useCallback(itemValue => {
const next = tempValue.includes(itemValue) ? tempValue.filter(v => v !== itemValue) : [...tempValue, itemValue];
pendingCheckedCountAnnouncementRef.current = true;
setTempValue(next);
if (!showConfirmButton) {
applyChange(next);
}
}, [tempValue, showConfirmButton, applyChange]);
const handleToggleParent = useCallback(item => {
const children = item.children ? flattenItems(item.children) : [];
const allChildValues = children.map(child => child.value);
const allChildrenChecked = allChildValues.every(childVal => tempValue.includes(childVal));
let next = [...tempValue];
if (allChildrenChecked) {
next = next.filter(v => ![item.value, ...allChildValues].includes(v));
} else {
next = Array.from(new Set([...next, ...allChildValues]));
}
pendingCheckedCountAnnouncementRef.current = true;
setTempValue(next);
if (!showConfirmButton) {
applyChange(next);
}
}, [tempValue, showConfirmButton, applyChange, flattenItems]);
const handleSelectAll = useCallback(() => {
const allFilteredFlat = flattenItems(filteredItems);
const selectableItems = allFilteredFlat.filter(item => !item.disabled);
const allSelectableChecked = selectableItems.every(item => tempValue.includes(item.value));
const next = allSelectableChecked ? tempValue.filter(v => !selectableItems.some(item => item.value === v)) : Array.from(new Set([...tempValue, ...selectableItems.map(item => item.value)]));
pendingCheckedCountAnnouncementRef.current = true;
setTempValue(next);
if (!showConfirmButton) {
applyChange(next);
}
}, [filteredItems, tempValue, showConfirmButton, applyChange, flattenItems]);
const handleRemoveTag = useCallback(itemValue => {
const item = allFlatItems.find(i => i.value === itemValue);
if (item?.disabled) {
return;
}
const next = tempValue.filter(v => v !== itemValue);
pendingCheckedCountAnnouncementRef.current = true;
setTempValue(next);
if (!showConfirmButton) {
applyChange(next);
}
}, [allFlatItems, tempValue, showConfirmButton, applyChange]);
const handleConfirm = useCallback(() => {
applyChange(tempValue);
confirmedRef.current = true;
setIsOpen(false);
}, [tempValue, applyChange]);
const handleCancel = useCallback(() => {
setTempValue(value || []);
setSearchValue('');
setIsOpen(false);
}, [value]);
const allFilteredFlat = useMemo(() => flattenItems(filteredItems), [filteredItems, flattenItems]);
const selectableFilteredFlat = useMemo(() => allFilteredFlat.filter(item => !item.disabled), [allFilteredFlat]);
const allFilteredSelected = selectableFilteredFlat.length > 0 && selectableFilteredFlat.every(item => tempValue.includes(item.value));
const someFilteredSelected = !allFilteredSelected && selectableFilteredFlat.some(item => tempValue.includes(item.value));
const getCheckboxes = useCallback(() => Array.from(popoverContentRef.current?.querySelectorAll('.dnb-checkbox__input:not(:disabled)') || []), []);
const getSearchInput = useCallback(() => {
var _popoverContentRef$cu;
return (_popoverContentRef$cu = popoverContentRef.current?.querySelector('.dnb-forms-field-multi-selection__search input:not(:disabled)')) !== null && _popoverContentRef$cu !== void 0 ? _popoverContentRef$cu : null;
}, []);
const handlePopoverKeyDown = useCallback(event => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
return;
}
event.preventDefault();
if (disabled) {
return;
}
const checkboxes = getCheckboxes();
const searchInput = getSearchInput();
const navigable = [...(searchInput ? [searchInput] : []), ...checkboxes];
if (!navigable.length) {
return;
}
const active = document.activeElement;
const rowCheckbox = active?.closest('.dnb-forms-field-multi-selection__item')?.querySelector('.dnb-checkbox__input');
const current = navigable.includes(active) ? active : rowCheckbox && navigable.includes(rowCheckbox) ? rowCheckbox : null;
const index = current ? navigable.indexOf(current) : -1;
const next = index === -1 ? event.key === 'ArrowDown' ? navigable[0] : navigable[navigable.length - 1] : event.key === 'ArrowDown' ? navigable[(index + 1) % navigable.length] : navigable[(index - 1 + navigable.length) % navigable.length];
next?.focus();
if (next !== searchInput) {
next?.closest('.dnb-forms-field-multi-selection__item')?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
}, [disabled, getCheckboxes, getSearchInput]);
const resolveFocusOnOpenElement = useCallback(() => {
const dir = pendingTriggerNavigationRef.current;
const checkboxes = getCheckboxes();
const searchInput = getSearchInput();
if (dir === null) {
return popoverContentRef.current;
}
if (dir === 1) {
return searchInput !== null && searchInput !== void 0 ? searchInput : checkboxes[0];
}
return checkboxes[checkboxes.length - 1];
}, [getCheckboxes, getSearchInput]);
const handleFocusComplete = useCallback(() => {
pendingTriggerNavigationRef.current = null;
}, []);
const fieldBlockProps = {
forId: id,
className: clsx('dnb-forms-field-multi-selection', className, isInline && 'dnb-forms-field-multi-selection--inline'),
contentClassName: 'dnb-forms-field-multi-selection__field-content',
disableStatusSummary: true,
asFieldset: isInline,
...pickSpacingProps(props)
};
if (!isInline) {
fieldBlockProps.contentWidth = width !== null && width !== void 0 ? width : 'large';
}
const handleTriggerKeyDown = useCallback(event => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
return;
}
event.preventDefault();
if (disabled) {
return;
}
if (isOpen) {
const checkboxes = getCheckboxes();
const searchInput = getSearchInput();
const target = event.key === 'ArrowDown' ? searchInput !== null && searchInput !== void 0 ? searchInput : checkboxes[0] : checkboxes[checkboxes.length - 1];
target?.focus();
return;
}
pendingTriggerNavigationRef.current = event.key === 'ArrowDown' ? 1 : -1;
setIsOpen(true);
}, [disabled, getCheckboxes, getSearchInput, isOpen]);
const searchContent = _jsx(MultiSelectionSearch, {
show: showSearchField,
placeholder: translation.searchPlaceholder,
value: searchValue,
disabled: disabled,
onSearchChange: setSearchValue
});
const itemListContent = _jsx(MultiSelectionItemList, {
disabled: disabled,
filteredItems: filteredItems,
tempValue: tempValue,
searchValue: searchValue,
showSelectAll: showSelectAll,
htmlAttributes: htmlAttributes,
translation: {
selectAll: translation.selectAll,
noOptions: translation.noOptions
},
getParentState: getParentState,
onToggleItem: handleToggleItem,
onToggleParent: handleToggleParent,
onToggleSelectAll: handleSelectAll,
selectableFilteredFlat: selectableFilteredFlat,
allFilteredSelected: allFilteredSelected,
someFilteredSelected: someFilteredSelected
});
const selectedTagsContent = _jsx(MultiSelectionSelectedTags, {
id: id,
show: showSelectedTags,
disabled: disabled,
isCollapsible: isCollapsible,
selectedItems: selectedItems,
totalCount: totalCount,
formatSelectionCount: formatSelectionCount,
translation: {
clearAll: translation.clearAll,
placeholder: translation.placeholder
},
onRemoveTag: handleRemoveTag,
onClearAll: () => {
const disabledValues = allFlatItems.filter(item => item.disabled && tempValue.includes(item.value)).map(item => item.value);
setTempValue(disabledValues);
createSharedState(`${id}-selected-accordion`).set({
expanded: true
});
if (!showConfirmButton) {
applyChange(disabledValues);
}
}
});
if (isInline) {
return _jsx(FieldBlock, {
...fieldBlockProps,
children: _jsxs("div", {
className: "dnb-forms-field-multi-selection__container",
children: [_jsx(AriaLive, {
priority: "high",
children: ariaLiveCheckedCount
}), _jsxs("div", {
className: "dnb-forms-field-multi-selection__inline-content",
children: [searchContent, selectedTagsContent, itemListContent]
})]
})
});
}
return _jsx(FieldBlock, {
...fieldBlockProps,
children: _jsxs("div", {
className: "dnb-forms-field-multi-selection__container",
children: [_jsx(AriaLive, {
priority: "high",
children: ariaLiveCheckedCount
}), _jsx(Popover, {
open: isOpen,
focusOnOpen: true,
focusOnOpenElement: resolveFocusOnOpenElement,
onFocusComplete: handleFocusComplete,
onOpenChange: open => {
setIsOpen(open);
if (!open && !confirmedRef.current) {
setTempValue(value || []);
setSearchValue('');
}
if (!open) {
confirmedRef.current = false;
}
},
placement: "bottom",
autoAlignViewportThreshold: 0.75,
horizontalOffset: width === 'medium' ? 40 : 0,
hideCloseButton: true,
noInnerSpace: !hasFeature,
hideArrow: true,
className: "dnb-forms-field-multi-selection__popover",
trigger: ({
active,
...triggerProps
}) => _jsx(MultiSelectionTrigger, {
id: id,
active: active,
disabled: disabled,
displayCount: displayCount,
totalCount: totalCount,
formatSelectionCount: formatSelectionCount,
onKeyDown: handleTriggerKeyDown,
triggerProps: triggerProps
}),
children: _jsxs("div", {
className: "dnb-forms-field-multi-selection__popover-content",
ref: handlePopoverContentRef,
tabIndex: -1,
onKeyDownCapture: handlePopoverKeyDown,
children: [searchContent, selectedTagsContent, itemListContent, _jsx(MultiSelectionActions, {
show: showConfirmButton,
disabled: disabled,
tempValueLength: tempValue.length,
formatMessage: formatMessage,
translation: {
confirmButton: translation.confirmButton,
cancelButton: translation.cancelButton
},
onConfirm: handleConfirm,
onCancel: handleCancel
})]
})
})]
})
});
}
withComponentMarkers(MultiSelection, {
_supportsSpacingProps: true
});
export default MultiSelection;
//# sourceMappingURL=MultiSelection.js.map