mirror of https://github.com/buster-so/buster.git
select input update
This commit is contained in:
parent
681f60a370
commit
50d6e7a929
|
@ -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",
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue