'use client'; import * as Ariakit from '@ariakit/react'; import React, { useMemo, useState, useId, useTransition, useEffect, useCallback } from 'react'; import MdHelpButton from '../help/MdHelpButton'; import MdHelpText from '../help/MdHelpText'; import MdIconClose from '../icons-material/MdIconClose'; import MdIconKeyboardArrowDown from '../icons-material/MdIconKeyboardArrowDown'; import MdIconSearch from '../icons-material/MdIconSearch'; import MdLoadingSpinner from '../loadingSpinner/MdLoadingSpinner'; import MdCheckbox from './MdCheckbox'; import type { MdComboBoxBaseProps } from './MdComboBox'; import type { MdComboBoxGroupedOption } from './MdComboBoxGrouped'; interface Labels { helpTextFor?: string; reset?: string; openClose?: string; } export interface MdComboBoxNestedProps extends Omit, 'value'>, MdComboBoxBaseProps { options: MdComboBoxGroupedOption[]; defaultOptions?: MdComboBoxGroupedOption[]; value: string[][]; hideSeparatorLine?: boolean; onSelectOption(_value: string[][]): void; } function normalizeString(str: string): string { return str .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .trim(); } function getTextByValue( options: MdComboBoxGroupedOption[], value: string, fallback: string, ): string { for (const group of options) { for (const v of group.values) { if (v.value === value) return v.text; } } return fallback; } function flattenValue(value: string[][]): string[] { return ([] as string[]).concat(...value); } function unflattenValue(flat: string[], options: MdComboBoxGroupedOption[]): string[][] { const selected = new Set(flat); return options.map(group => { return group.values.filter(v => { return selected.has(v.value) }).map(v => { return v.value })} ); } function filterGroupedOptions( options: MdComboBoxGroupedOption[], searchValue: string, defaultOptions?: MdComboBoxGroupedOption[], numberOfElementsShown?: number, ): (MdComboBoxGroupedOption & { _originalIndex: number })[] { if (!searchValue && defaultOptions && defaultOptions.length > 0) { return defaultOptions.map((g, i) => {return { ...g, _originalIndex: i }}); } const normalizedSearch = normalizeString(searchValue || ''); const results = options .map((group, groupIndex) => { const matchingValues = group.values.filter(v => { const nt = normalizeString(v.text || ''); const nv = normalizeString(v.value || ''); return nt.includes(normalizedSearch) || nv.includes(normalizedSearch); }); const groupLabelMatches = normalizeString(group.label || '').includes(normalizedSearch); if (matchingValues.length > 0 || groupLabelMatches) { return { ...group, values: groupLabelMatches ? group.values : matchingValues, _originalIndex: groupIndex, }; } return null; }) .filter((g): g is MdComboBoxGroupedOption & { _originalIndex: number } => {return g !== null}); return numberOfElementsShown ? results.slice(0, numberOfElementsShown) : results; } export function toggleGroupSelection( groupIndex: number, options: MdComboBoxGroupedOption[], currentFlat: string[], ): string[] { const group = options[groupIndex]; if (!group) return currentFlat; const groupValues = group.values.map(v => {return v.value}); const allSelected = groupValues.every(v => {return currentFlat.includes(v)}); if (allSelected) { const groupSet = new Set(groupValues); return currentFlat.filter(v => {return !groupSet.has(v)}); } else { const existing = new Set(currentFlat); groupValues.forEach(v => {return existing.add(v)}); return Array.from(existing); } } export function computeNestedDisplayValue( selectedFlat: string[], options: MdComboBoxGroupedOption[], getTextByValue: (val: string) => string, placeholder: string, ): string { if (selectedFlat.length === 0) return placeholder; const fullySelectedGroup = options.find( group => { return group.values.length > 0 && group.values.every(v => { return selectedFlat.includes(v.value) }) }); if (fullySelectedGroup) return fullySelectedGroup.label; return getTextByValue(selectedFlat[0]); } const MdComboBoxNested = React.forwardRef( ( { id, label, labels = {}, options, defaultOptions, value, disabled = false, placeholder = 'Søk', numberOfElementsShown, mode = 'medium', helpText, error = false, errorText, noResultsText = 'Ingen treff', dropdownHeight, prefixIcon, isSearching = false, hidePrefixIcon = false, allowReset = false, flip = false, hideSeparatorLine = false, onSelectOption, unmountOnHide, ...otherProps }, ref, ) => { const uuid = `combobox_nested_${useId()}`; const comboBoxId = id || uuid; const [isPending, startTransition] = useTransition(); const [searchValue, setSearchValue] = useState(''); const [selectedFlat, setSelectedFlat] = useState(() => {return flattenValue(value)}); const [helpOpen, setHelpOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); const [pendingSearchClear, setPendingSearchClear] = useState(false); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const store = Ariakit.useComboboxStore(); const defaultLabels: Required = { helpTextFor: 'Hjelpetekst for', reset: 'Nullstill', openClose: 'Åpne/lukke liste', }; const mergedLabels: Required = { ...defaultLabels, ...labels }; useEffect(() => { setSelectedFlat(flattenValue(value)); }, [value]); useEffect(() => { if (!pendingSearchClear) return; const checkAnimationEnd = () => { const state = store.getState(); if (!state.animating && !state.open) { setSearchValue(''); setPendingSearchClear(false); } else { requestAnimationFrame(checkAnimationEnd); } }; requestAnimationFrame(checkAnimationEnd); }, [store, pendingSearchClear]); const matches = useMemo( () => {return filterGroupedOptions(options, searchValue, defaultOptions, numberOfElementsShown)}, [searchValue, defaultOptions, options, numberOfElementsShown], ); const resolveText = useCallback( (val: string) => {return getTextByValue(options, val, placeholder)}, [options, placeholder], ); const emitChange = useCallback((newFlat: string[]) => { setSelectedFlat(newFlat); onSelectOption(unflattenValue(newFlat, options)); }, [onSelectOption, options]); const toggleGroup = useCallback( (groupIndex: number) => {return emitChange(toggleGroupSelection(groupIndex, options, selectedFlat))}, [options, selectedFlat, emitChange], ); const toggleExpanded = useCallback((groupIndex: number) => { setExpandedGroups(prev => { const next = new Set(prev); if (next.has(groupIndex)) { next.delete(groupIndex); } else { next.add(groupIndex); } return next; }); }, []); const onReset = () => { setSearchValue(''); emitChange([]); }; const displayValue = useMemo( () => {return computeNestedDisplayValue(selectedFlat, options, resolveText, placeholder)}, [selectedFlat, options, resolveText, placeholder], ); let ariaDescribedBy = helpText && helpText !== '' ? `md-combobox_help-text_${comboBoxId}` : undefined; ariaDescribedBy = error && errorText && errorText !== '' ? `md-combobox_error_${comboBoxId}` : ariaDescribedBy; const showLabel = (label && label !== '') || (helpText && helpText !== ''); const getOpenState = () => {return store.getState().open}; return (
0 && 'md-combobox--has-value'}`} > { const flat = Array.isArray(values) ? Array.from(values) as string[] : [values as string]; emitChange(flat); }} setValue={val => { startTransition(() => { setSearchValue(val); }); }} setOpen={() => { setPopoverOpen(getOpenState()); }} > {showLabel && (
{label && label !== '' && {label}} {helpText && helpText !== '' && (
{return setHelpOpen(!helpOpen)}} expanded={helpOpen} />
)}
{helpText && helpText !== '' && (
{helpText}
)}
)}
{!hidePrefixIcon && (
{isSearching ? : prefixIcon ? prefixIcon : }
)}
{selectedFlat.length > 0 && `+${selectedFlat.length}`}
{allowReset && (selectedFlat.length > 0 || searchValue !== '') && ( )}
{return setPendingSearchClear(true)}} > {matches && matches.map((group, index) => { const groupIndex = group._originalIndex; const groupValues = group.values.map(v => {return v.value}); const selectedInGroup = groupValues.filter(v => {return selectedFlat.includes(v)}); const allGroupSelected = groupValues.length > 0 && selectedInGroup.length === groupValues.length; const someGroupSelected = selectedInGroup.length > 0 && !allGroupSelected; const isExpanded = expandedGroups.has(groupIndex) || searchValue !== ''; return ( {!hideSeparatorLine && index !== 0 && (

)}
{return false}} aria-selected={allGroupSelected} onClick={() => {return toggleExpanded(groupIndex)}} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(groupIndex); } else if (e.key === 'ArrowRight') { e.preventDefault(); if (!isExpanded) toggleExpanded(groupIndex); } else if (e.key === 'ArrowLeft') { e.preventDefault(); if (isExpanded) toggleExpanded(groupIndex); } }} > {return toggleGroup(groupIndex)}} tabIndex={-1} /> {selectedInGroup.length > 0 && ( {selectedInGroup.length}/{groupValues.length} )} {isExpanded && group.values.map((option, i) => { const isChecked = selectedFlat.includes(option.value); return ( {return false}} className="md-combobox__checkbox-item" aria-selected={isChecked} style={{ paddingLeft: '2rem' }} > ); })}
); })} {!matches.length && (
{noResultsText}
)}
{error && errorText && errorText !== '' && (
{errorText}
)}
); }, ); MdComboBoxNested.displayName = 'MdComboBoxNested'; export default MdComboBoxNested;