'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'; interface Labels { helpTextFor?: string; reset?: string; openClose?: string; } export interface MdComboBoxOption { value: string; text: string; } export interface MdComboBoxBaseProps { id?: string; label?: string; labels?: Labels; disabled?: boolean; error?: boolean; errorText?: string; placeholder?: string; helpText?: string; numberOfElementsShown?: number; isSearching?: boolean; mode?: 'large' | 'medium' | 'small'; noResultsText?: string; dropdownHeight?: number; prefixIcon?: React.ReactNode; hidePrefixIcon?: boolean; allowReset?: boolean; flip?: boolean; /** * When `true`, the popover will be unmounted when it is hidden. This can be useful for performance reasons, but it may cause issues with animations or transitions. * @default false * @see https://ariakit.org/reference/combobox-popover#unmountonhide */ unmountOnHide?: boolean; } export interface MdComboBoxProps extends React.InputHTMLAttributes, MdComboBoxBaseProps { options: MdComboBoxOption[]; defaultOptions?: MdComboBoxOption[]; value: string | string[]; onSelectOption(_value: string[] | string): void; } const MdComboBox = 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, onSelectOption, unmountOnHide, ...otherProps }, ref, ) => { const uuid = `combobox_${useId()}`; const comboBoxId = id || uuid; const isMultiSelect = Array.isArray(value); const [isPending, startTransition] = useTransition(); const [searchValue, setSearchValue] = useState(''); const [selectedValues, setSelectedValues] = useState(value); const [helpOpen, setHelpOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); const [pendingSearchClear, setPendingSearchClear] = useState(false); const store = Ariakit.useComboboxStore(); const defaultLabels: Required = { helpTextFor: 'Hjelpetekst for', reset: 'Nullstill', openClose: 'Åpne/lukke liste', }; const mergedLabels: Required = { ...defaultLabels, ...labels }; useEffect(() => { setSelectedValues(value); if (!value) { setSearchValue(''); } }, [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 normalizeString = (str: string) => { return str .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .trim(); }; const matches = useMemo(() => { if (!searchValue && defaultOptions && defaultOptions.length > 0) { return defaultOptions; } const normalizedSearch = normalizeString(searchValue || ''); const results = options?.filter(o => { const normalizedText = normalizeString(o.text || ''); const normalizedValue = normalizeString(o.value || ''); return normalizedText.includes(normalizedSearch) || normalizedValue.includes(normalizedSearch); }); return numberOfElementsShown ? results.slice(0, numberOfElementsShown) : results; }, [searchValue, defaultOptions, options, numberOfElementsShown]); const getValueById = useMemo(() => { return (value: string) => { const option = options.find(option => { return option.value === value; }); return option ? option.text : placeholder; }; }, [options, placeholder]); const onReset = () => { const newValue = isMultiSelect ? [] : ''; setSearchValue(''); if (value) { setSelectedValues(newValue); onSelectOption(newValue); } }; let displayValue: string | string[] = placeholder; if (isMultiSelect) { displayValue = selectedValues.length > 0 ? getValueById(selectedValues[0]) : placeholder; } else if (selectedValues !== '') { displayValue = getValueById(selectedValues as string); } 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 setItemCallback = useCallback(() => { if (!isMultiSelect) { setSearchValue(''); } return false; }, [isMultiSelect]); const getOpenState = () => { return store.getState().open; }; return (
0 : true) && 'md-combobox--has-value'}`} > { const mutableValues = Array.isArray(values) ? (Array.from(values) as string[]) : (values as string); setSelectedValues(mutableValues); onSelectOption(mutableValues); }} 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 : }
)}
{isMultiSelect && selectedValues.length > 0 && `+${selectedValues.length}`}
{allowReset && (selectedValues.length > 0 || searchValue !== '') && ( )}
{ setPendingSearchClear(true); }} > {matches && matches.map(option => { const isChecked = isMultiSelect ? selectedValues.toString().includes(option.value) : selectedValues === option.value; return ( {isMultiSelect ? ( ) : ( option.text )} ); })} {!matches.length && (
{noResultsText}
)}
{error && errorText && errorText !== '' && (
{errorText}
)}
); }, ); MdComboBox.displayName = 'MdComboBox'; export default MdComboBox;