ui(components): Adds InputFilter component to ui-next (#4848)

This commit is contained in:
Dan Rukas 2025-03-17 11:04:42 -04:00 committed by GitHub
parent 841320adfb
commit f916b2f40e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 225 additions and 7 deletions

View File

@ -2,7 +2,7 @@ import dcmjs from 'dcmjs';
import moment from 'moment';
import React, { useState, useMemo, useCallback } from 'react';
import { classes, Types } from '@ohif/core';
import { InputFilterText } from '@ohif/ui';
import { InputFilter } from '@ohif/ui-next';
import { Select, SelectTrigger, SelectContent, SelectItem, Slider } from '@ohif/ui-next';
import DicomTagTable from './DicomTagTable';
@ -173,11 +173,17 @@ const DicomTagBrowser = ({
<span className="text-muted-foreground flex h-6 items-center text-xs">
Search metadata
</span>
<InputFilterText
placeholder="Search metadata..."
onDebounceChange={setFilterValue}
className="text-foreground"
/>
<InputFilter
className="text-muted-foreground"
onChange={setFilterValue}
>
<InputFilter.SearchIcon />
<InputFilter.Input
placeholder="Search metadata"
className="pl-9 pr-9"
/>
<InputFilter.ClearButton />
</InputFilter>
</div>
</div>
</div>

View File

@ -0,0 +1,206 @@
import * as React from 'react';
import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import debounce from 'lodash.debounce';
import { cn } from '../../lib/utils';
import { Input } from '../Input';
import { Icons } from '../Icons';
// Context to share state between compound components
type InputFilterContextType = {
value: string;
setValue: (value: string) => void;
handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
clearValue: () => void;
inputRef: React.RefObject<HTMLInputElement>;
};
const InputFilterContext = createContext<InputFilterContextType | undefined>(undefined);
function useInputFilterContext() {
const context = useContext(InputFilterContext);
if (!context) {
throw new Error('InputFilter compound components must be used within InputFilter');
}
return context;
}
// Root component
interface RootProps {
children: React.ReactNode;
className?: string;
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
debounceTime?: number;
}
function Root({
children,
className,
defaultValue = '',
value: controlledValue,
onChange,
debounceTime = 200,
}: RootProps) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
const inputRef = React.useRef<HTMLInputElement>(null);
// Determine if this is a controlled or uncontrolled component
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : uncontrolledValue;
const debouncedOnChange = useMemo(() => {
return onChange ? debounce(onChange, debounceTime) : undefined;
}, [onChange, debounceTime]);
useEffect(() => {
return () => debouncedOnChange?.cancel();
}, [debouncedOnChange]);
const setValue = useCallback(
(newValue: string) => {
if (!isControlled) {
setUncontrolledValue(newValue);
}
if (debouncedOnChange) {
debouncedOnChange(newValue);
}
},
[isControlled, debouncedOnChange]
);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
},
[setValue]
);
const clearValue = useCallback(() => {
setValue('');
inputRef.current?.focus();
}, [setValue]);
return (
<InputFilterContext.Provider value={{ value, setValue, handleChange, clearValue, inputRef }}>
<div className={cn('relative', className)}>{children}</div>
</InputFilterContext.Provider>
);
}
// Input component
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
className?: string;
}
function InputComponent({ className, placeholder, ...props }: InputProps) {
const { value, handleChange, inputRef } = useInputFilterContext();
return (
<Input
ref={inputRef}
className={cn('w-full', className)}
placeholder={placeholder}
value={value}
onChange={handleChange}
{...props}
/>
);
}
// Search icon component
interface SearchIconProps {
className?: string;
}
function SearchIcon({ className }: SearchIconProps) {
return (
<span className={cn('absolute inset-y-0 left-0 flex items-center pl-2', className)}>
<Icons.Search className="text-muted-foreground" />
</span>
);
}
// Clear button component
interface ClearButtonProps {
className?: string;
}
function ClearButton({ className }: ClearButtonProps) {
const { value, clearValue } = useInputFilterContext();
if (!value) {
return null;
}
return (
<span className={cn('absolute inset-y-0 right-0 flex items-center pr-2', className)}>
<Icons.Clear
className={cn('cursor-pointer', className)}
onClick={clearValue}
/>
</span>
);
}
// Main InputFilter component
interface InputFilterProps {
children?: React.ReactNode;
className?: string;
placeholder?: string;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
debounceTime?: number;
}
function InputFilter({
children,
className,
placeholder = 'Search...',
value,
defaultValue = '',
onChange,
debounceTime = 200,
}: InputFilterProps) {
// If children are provided, act as a container (former Root)
if (children) {
return (
<Root
className={className}
value={value}
defaultValue={defaultValue}
onChange={onChange}
debounceTime={debounceTime}
>
{children}
</Root>
);
}
// Otherwise act as the complete pre-composed component
return (
<Root
className={className}
value={value}
defaultValue={defaultValue}
onChange={onChange}
debounceTime={debounceTime}
>
<SearchIcon />
<InputComponent
placeholder={placeholder}
className="pl-9 pr-9"
/>
<ClearButton />
</Root>
);
}
// Attach subcomponents as static properties
InputFilter.Input = InputComponent;
InputFilter.SearchIcon = SearchIcon;
InputFilter.ClearButton = ClearButton;
export { InputFilter, type InputFilterProps };

View File

@ -0,0 +1,2 @@
export { InputFilter } from './InputFilter';
export type { InputFilterProps } from './InputFilter';

View File

@ -58,6 +58,7 @@ import { InputDialog, PresetDialog } from './OHIFDialogs';
import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals';
import Modal from './Modal/Modal';
import { FooterAction } from './FooterAction';
import { InputFilter } from './InputFilter';
import {
DropdownMenu,
@ -237,4 +238,5 @@ export {
UserPreferencesModal,
FooterAction,
ToolSettings,
InputFilter,
};

View File

@ -107,6 +107,7 @@ import {
ImageModal,
UserPreferencesModal,
FooterAction,
InputFilter,
} from './components';
import { DataRow } from './components/DataRow';
@ -245,4 +246,5 @@ export {
useDialog,
ManagedDialog,
ToolSettings,
};
InputFilter,
};