UNPKG

@dnb/eufemia

Version:

DNB Eufemia Design System UI Library

511 lines (510 loc) 20.6 kB
"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