diff --git a/src/components/SearchableMultiSelect/Loader.tsx b/src/components/SearchableMultiSelect/Loader.tsx new file mode 100644 index 00000000..3e065e9f --- /dev/null +++ b/src/components/SearchableMultiSelect/Loader.tsx @@ -0,0 +1,24 @@ +import { CircularLoader } from '@dhis2/ui' +import * as React from 'react' +import { forwardRef } from 'react' + +export const Loader = forwardRef(function Loader( + _, + ref +) { + return ( +
+ +
+ ) +}) diff --git a/src/components/SearchableMultiSelect/SearchableMultiSelect.module.css b/src/components/SearchableMultiSelect/SearchableMultiSelect.module.css new file mode 100644 index 00000000..d78ac769 --- /dev/null +++ b/src/components/SearchableMultiSelect/SearchableMultiSelect.module.css @@ -0,0 +1,68 @@ +.invisibleOption { + display: none; +} + +.loader { + height: 80px; + display: flex; + align-items: center; + justify-content: center; + padding-top: 20px; + overflow: hidden; +} + +.error { + display: flex; + justify-content: center; + font-size: 14px; + padding: var(--spacers-dp12) 16px; +} + +.errorInnerWrapper { + display: flex; + flex-direction: column; + justify-content: center; +} + +.loadingErrorLabel { + color: var(--theme-error); +} + +.errorRetryButton { + background: none; + padding: 0; + border: 0; + outline: 0; + text-decoration: underline; + cursor: pointer; +} + +.searchField { + display: flex; + gap: var(--spacers-dp8); + position: sticky; + top: 0; + padding: var(--spacers-dp16); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.4); + background: var(--colors-white); +} + +.searchInput { + flex-grow: 1; +} + +.clearButton { + font-size: 14px; + flex-grow: 0; + background: none; + padding: 0; + border: 0; + outline: 0; + text-decoration: underline; + cursor: pointer; +} + +.clearButton:hover { + color: var(--theme-valid); + text-decoration: none; +} diff --git a/src/components/SearchableMultiSelect/SearchableMultiSelect.tsx b/src/components/SearchableMultiSelect/SearchableMultiSelect.tsx new file mode 100644 index 00000000..4ceeb1b5 --- /dev/null +++ b/src/components/SearchableMultiSelect/SearchableMultiSelect.tsx @@ -0,0 +1,167 @@ +import i18n from '@dhis2/d2-i18n' +import { + CircularLoader, + Input, + MultiSelect, + MultiSelectOption, + MultiSelectOptionProps, + MultiSelectProps, + SingleSelect, + SingleSelectOption, +} from '@dhis2/ui' +import React, { forwardRef, useEffect, useState } from 'react' +import { useDebouncedState } from '../../lib' +import classes from './SearchableMultiSelect.module.css' + +export interface Option { + value: string + label: string +} + +const Loader = forwardRef(function Loader(_, ref) { + return ( +
+ +
+ ) +}) + +function Error({ + msg, + onRetryClick, +}: { + msg: string + onRetryClick: () => void +}) { + return ( +
+
+ {msg} + +
+
+ ) +} + +type OwnProps = { + onEndReached?: () => void + onFilterChange: ({ value }: { value: string }) => void + onRetryClick: () => void + options: Array<{ value: string; label: string }> + showEndLoader?: boolean + error?: string +} + +export type SearchableMultiSelectPropTypes = Omit< + MultiSelectProps, + keyof OwnProps +> & + OwnProps +export const SearchableMultiSelect = ({ + disabled, + error, + dense, + loading, + placeholder, + prefix, + onBlur, + onChange, + onEndReached, + onFilterChange, + onFocus, + onRetryClick, + options, + selected, + showEndLoader, +}: SearchableMultiSelectPropTypes) => { + const [loadingSpinnerRef, setLoadingSpinnerRef] = useState() + + const { liveValue: filter, setValue: setFilterValue } = + useDebouncedState({ + initialValue: '', + onSetDebouncedValue: (value: string) => onFilterChange({ value }), + }) + + useEffect(() => { + // We don't want to wait for intersections when loading as that can + // cause buggy behavior + if (loadingSpinnerRef && !loading) { + const observer = new IntersectionObserver( + (entries) => { + const [{ isIntersecting }] = entries + + if (isIntersecting) { + onEndReached?.() + } + }, + { threshold: 0.8 } + ) + + observer.observe(loadingSpinnerRef) + return () => observer.disconnect() + } + }, [loadingSpinnerRef, loading, onEndReached]) + + const hasSelectedInOptionList = !!options.find( + ({ value }) => value === selected + ) + + return ( + 1} + > +
+
+ setFilterValue(value ?? '')} + placeholder={i18n.t('Filter options')} + /> +
+ +
+ + {options.map(({ value, label }) => ( + + ))} + + {!error && !loading && showEndLoader && ( + { + if (!!ref && ref !== loadingSpinnerRef) { + setLoadingSpinnerRef(ref) + } + }} + /> + )} + + {!error && loading && } + + {error && } +
+ ) +} diff --git a/src/components/SearchableMultiSelect/index.ts b/src/components/SearchableMultiSelect/index.ts new file mode 100644 index 00000000..216835ed --- /dev/null +++ b/src/components/SearchableMultiSelect/index.ts @@ -0,0 +1 @@ +export * from './SearchableMultiSelect'