Skip to content

Commit

Permalink
feat: add searchablemultiselect
Browse files Browse the repository at this point in the history
  • Loading branch information
Birkbjo committed Nov 18, 2024
1 parent dbfca87 commit 977d9a8
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 0 deletions.
24 changes: 24 additions & 0 deletions src/components/SearchableMultiSelect/Loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CircularLoader } from '@dhis2/ui'
import * as React from 'react'
import { forwardRef } from 'react'

export const Loader = forwardRef<HTMLDivElement, object>(function Loader(
_,
ref
) {
return (
<div
ref={ref}
style={{
height: 80,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
paddingTop: 20,
overflow: 'hidden',
}}
>
<CircularLoader />
</div>
)
})
Original file line number Diff line number Diff line change
@@ -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;
}
167 changes: 167 additions & 0 deletions src/components/SearchableMultiSelect/SearchableMultiSelect.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, object>(function Loader(_, ref) {
return (
<div ref={ref} className={classes.loader}>
<CircularLoader />
</div>
)
})

function Error({
msg,
onRetryClick,
}: {
msg: string
onRetryClick: () => void
}) {
return (
<div className={classes.error}>
<div className={classes.errorInnerWrapper}>
<span className={classes.loadingErrorLabel}>{msg}</span>
<button
className={classes.errorRetryButton}
type="button"
onClick={onRetryClick}
>
{i18n.t('Retry')}
</button>
</div>
</div>
)
}

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<HTMLElement>()

const { liveValue: filter, setValue: setFilterValue } =
useDebouncedState<string>({
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 (
<MultiSelect
selected={selected}
disabled={disabled}
error={!!error}
onChange={onChange}
placeholder={placeholder}
prefix={prefix}
onBlur={onBlur}
onFocus={onFocus}
dense={dense}
clearable={selected && selected.length > 1}
>
<div className={classes.searchField}>
<div className={classes.searchInput}>
<Input
dense
initialFocus
value={filter}
onChange={({ value }) => setFilterValue(value ?? '')}
placeholder={i18n.t('Filter options')}
/>
</div>
<button
className={classes.clearButton}
disabled={!filter}
onClick={() => setFilterValue('')}
type="button"
>
clear
</button>
</div>

{options.map(({ value, label }) => (
<MultiSelectOption key={value} value={value} label={label} />
))}

{!error && !loading && showEndLoader && (
<Loader
ref={(ref) => {
if (!!ref && ref !== loadingSpinnerRef) {
setLoadingSpinnerRef(ref)
}
}}
/>
)}

{!error && loading && <Loader />}

{error && <Error msg={error} onRetryClick={onRetryClick} />}
</MultiSelect>
)
}
1 change: 1 addition & 0 deletions src/components/SearchableMultiSelect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SearchableMultiSelect'

0 comments on commit 977d9a8

Please sign in to comment.