select input update

This commit is contained in:
Nate Kelley 2025-07-16 16:49:42 -06:00
parent 681f60a370
commit 50d6e7a929
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 97 additions and 60 deletions

View File

@ -85,7 +85,7 @@
"dom-to-image": "^2.6.0",
"email-validator": "^2.0.4",
"font-color-contrast": "^11.1.0",
"framer-motion": "^12.23.5",
"framer-motion": "^12.23.6",
"hono": "catalog:",
"html-react-parser": "^5.2.5",
"intersection-observer": "^0.12.2",

View File

@ -14,6 +14,7 @@ import {
import { useShareMetric, useUnshareMetric, useUpdateMetricShare } from '@/api/buster_rest/metrics';
import { Button } from '@/components/ui/buttons';
import { Input } from '@/components/ui/inputs';
import { InputSearchDropdown } from '@/components/ui/inputs/InputSearchDropdown';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { useMemoizedFn } from '@/hooks';
import { cn } from '@/lib/classMerge';
@ -159,10 +160,6 @@ const ShareMenuContentShare: React.FC<ShareMenuContentBodyProps> = React.memo(
}
});
const onChangeInputValue = useMemoizedFn((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
});
const onChangeAccessDropdown = useMemoizedFn((level: ShareRole | null) => {
if (level) setDefaultPermissionLevel(level);
});
@ -172,13 +169,24 @@ const ShareMenuContentShare: React.FC<ShareMenuContentBodyProps> = React.memo(
{canEditPermissions && (
<div className="flex h-full items-center space-x-2">
<div className="relative flex w-full items-center">
<Input
{/* <Input
className="w-full"
placeholder="Invite others by email..."
value={inputValue}
onChange={onChangeInputValue}
onPressEnter={onSubmitNewEmail}
autoComplete="off"
/> */}
<InputSearchDropdown
options={[]}
onSelect={() => {}}
onSearch={() => {}}
onPressEnter={() => {}}
value={inputValue}
onChange={setInputValue}
placeholder="Invite others by email..."
className="w-full"
/>
{inputValue && (

View File

@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { useState } from 'react';
import { InputSearchDropdown, type InputSearchDropdownProps } from './InputSearchDropdown';
import { useDebounceFn, useMemoizedFn } from '../../../hooks';
const meta: Meta<typeof InputSearchDropdown> = {
title: 'UI/inputs/InputSearchDropdown',
@ -36,25 +37,35 @@ const sampleOptions = [
// Interactive story with state management
const InputSearchDropdownWithState = (
args: Omit<InputSearchDropdownProps, 'options' | 'onSelect' | 'onSearch' | 'value'> & {
args: Omit<
InputSearchDropdownProps,
'options' | 'onSelect' | 'onSearch' | 'value' | 'onPressEnter'
> & {
value?: string;
}
) => {
const [value, setValue] = useState(args.value || '');
const [filteredOptions, setFilteredOptions] = useState(sampleOptions);
const handleSearch = (searchValue: string) => {
action('searched')(searchValue);
const filtered = sampleOptions.filter((option) =>
option.label.toLowerCase().includes(searchValue.toLowerCase())
);
setFilteredOptions(filtered);
};
const { run: handleSearch, cancel } = useDebounceFn(
async (searchValue: string) => {
action('searched')(searchValue);
const filtered = sampleOptions.filter((option) =>
option.label.toLowerCase().includes(searchValue.toLowerCase())
);
setFilteredOptions(filtered);
},
{ wait: 600 }
);
const handleSelect = (selectedValue: string) => {
const handleSelect = useMemoizedFn((selectedValue: string) => {
action('selected')(selectedValue);
setValue(selectedValue);
};
});
const handlePressEnter = useMemoizedFn((value: string) => {
action('pressedEnter')(value);
});
return (
<div className="w-80">
@ -63,7 +74,9 @@ const InputSearchDropdownWithState = (
options={filteredOptions}
onSearch={handleSearch}
onSelect={handleSelect}
onPressEnter={handlePressEnter}
/>
<div className="mt-2 w-fit rounded border border-red-500 p-1">{value}</div>
</div>
);
};

View File

@ -1,5 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
'use client';
import React, { useState, useMemo } from 'react';
import { Select } from '../select/Select';
import { useMemoizedFn } from '@/hooks';
export interface InputSearchDropdownProps {
options: {
@ -9,26 +12,44 @@ export interface InputSearchDropdownProps {
onSelect: (value: string) => void;
placeholder?: string;
emptyMessage?: string | false;
onSearch: (value: string) => void | ((value: string) => Promise<void>);
onSearch: ((value: string) => Promise<void>) | ((value: string) => void);
className?: string;
disabled?: boolean;
matchPopUpWidth?: boolean;
value?: string;
onChange?: (value: string) => void;
onPressEnter: (value: string) => void;
}
export const InputSearchDropdown = ({
options,
onSelect,
onPressEnter,
placeholder = 'Search...',
emptyMessage = false,
matchPopUpWidth = true,
onSearch,
value,
className,
onChange,
disabled = false
}: InputSearchDropdownProps) => {
const [inputValue, setInputValue] = useState(value);
const handleSearch = useMemo(() => {
return {
type: 'async' as const,
fn: async (searchTerm: string) => {
await onSearch(searchTerm);
}
};
}, [onSearch]);
const handleChange = useMemoizedFn((value: string) => {
setInputValue(value);
onChange?.(value);
});
return (
<Select
items={options}
@ -40,9 +61,10 @@ export const InputSearchDropdown = ({
matchPopUpWidth={matchPopUpWidth}
emptyMessage={emptyMessage}
inputValue={inputValue}
onInputValueChange={setInputValue}
search={true}
onInputValueChange={handleChange}
search={handleSearch}
hideChevron={true}
onPressEnter={onPressEnter}
/>
);
};

View File

@ -112,44 +112,35 @@ export const AsyncSearch: Story = {
const [inputValue, setInputValue] = React.useState('');
const [selectedValue, setSelectedValue] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout>();
const { run: debouncedSetInputValue } = useDebounceFn(
async (value: string) => {
const { run: debouncedSearch } = useDebounceFn(
async (searchTerm: string) => {
if (!searchTerm) {
setItems([]);
setIsLoading(false);
return;
}
setIsLoading(true);
// Simulate API call with delay
await new Promise((resolve) => setTimeout(resolve, 500));
// Filter users based on search term
const filtered = userItems.filter((item) => {
const labelText = typeof item.label === 'string' ? item.label : '';
return (
labelText.toLowerCase().includes(inputValue.toLowerCase()) ||
(item.secondaryLabel?.toLowerCase().includes(inputValue.toLowerCase()) ?? false)
labelText.toLowerCase().includes(searchTerm.toLowerCase()) ||
(item.secondaryLabel?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false)
);
});
setItems(filtered);
setIsLoading(false);
},
{ wait: 500 }
{ wait: 300 }
);
// Simulate API search with debouncing
React.useEffect(() => {
if (!inputValue) {
setItems([]);
return;
}
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setIsLoading(true);
debouncedSetInputValue(inputValue);
}, [inputValue]);
return (
<div>
<p style={{ marginTop: '10px', fontSize: '14px' }}>
@ -163,7 +154,14 @@ export const AsyncSearch: Story = {
inputValue={inputValue}
onInputValueChange={setInputValue}
placeholder="Search users (async)..."
search={true}
search={{
type: 'async',
fn: (searchTerm: string) => {
debouncedSearch(searchTerm);
return Promise.resolve();
}
}}
loading={isLoading}
emptyMessage={inputValue ? 'No users found' : 'Type to search users'}
/>
</div>

View File

@ -56,6 +56,7 @@ interface BaseSelectProps<T> {
onInputValueChange?: (value: string) => void;
hideChevron?: boolean;
closeOnSelect?: boolean;
onPressEnter?: (value: string) => void;
}
// Clearable version - onChange can return null
@ -162,6 +163,7 @@ function SelectComponent<T = string>({
inputValue,
onInputValueChange,
hideChevron = false,
onPressEnter,
closeOnSelect = true
}: SelectProps<T>) {
const [internalInputValue, setInternalInputValue] = React.useState('');
@ -216,9 +218,6 @@ function SelectComponent<T = string>({
if (search.type === 'filter') {
return search.fn(item, currentInputValue);
}
if (search.type === 'async') {
search.fn(currentInputValue);
}
return true;
}
@ -264,22 +263,15 @@ function SelectComponent<T = string>({
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
// If we have async loading, use debounced version
if (onInputValueChange) {
// Update internal state immediately for UI responsiveness
if (!inputValue) {
setInternalInputValue(newValue);
}
// Debounce the external callback
setInputValue(newValue);
} else {
// No debouncing, update immediately
setInputValue(newValue);
}
setInternalInputValue?.(newValue);
setInputValue?.(newValue);
if (search !== false && newValue && !open) {
handleOpenChange(true);
}
if (search && typeof search === 'object' && search.type === 'async') {
search.fn(newValue);
}
},
[search, open, handleOpenChange, setInputValue, onInputValueChange, inputValue]
);
@ -293,6 +285,9 @@ function SelectComponent<T = string>({
const handleInputKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && onPressEnter) {
return onPressEnter(e.currentTarget.value);
}
if (['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(e.key)) {
// Forward the event to the command component
if (commandRef.current) {
@ -402,6 +397,7 @@ function SelectComponent<T = string>({
onKeyDown={handleInputKeyDown}
placeholder={computedPlaceholder}
data-testid={dataTestId}
autoComplete="off"
readOnly={search === false}
className={cn(
'flex h-7 w-full items-center justify-between rounded border px-2.5 text-base',