Выпадающая часть от пользовательского множественного сектора вызывает вертикальную прокрутку и рендеринг внутри формы - CSS

Разбираемся в CSS
Ответить Пред. темаСлед. тема
Anonymous
 Выпадающая часть от пользовательского множественного сектора вызывает вертикальную прокрутку и рендеринг внутри формы -

Сообщение Anonymous »

Я использую пользовательский компонент с несколькими селекторами на основе CMDK (например, в коде ниже), но я сталкиваюсь с двумя проблемами макета, когда появляется выпадающая часть:
Выпачающая списка заставляет всю форму выращивать вертикально, выбирая вертикальный Scrollbar на странице. рендерирован внутри потока формы, требуя от пользователя прокручивать вниз, чтобы увидеть параметры. < /p>
"use client";

import { Command as CommandPrimitive, useCommandState } from "cmdk";
import { X } from "lucide-react";
import * as React from "react";
import { forwardRef, useEffect } from "react";

import { Badge } from "@/components/ui/badge";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";

export interface Option {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}

interface MultipleSelectorProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef,
"value" | "placeholder" | "disabled"
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
}

export interface MultipleSelectorRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}

export function useDebounce(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState(value);

useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);

return () => {
clearTimeout(timer);
};
}, [value, delay]);

return debouncedValue;
}

function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
"": options,
};
}

const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || "";
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}

function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;

for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter(
(val) => !picked.find((p) => p.value === val.value)
);
}
return cloneOption;
}

function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (
value.some((option) => targetOption.find((p) => p.value === option.value))
) {
return true;
}
}
return false;
}

/**
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
* So we create one and copy the `Empty` implementation from `cmdk`.
*
* @reference: https://github.com/hsuanyi-chou/shadcn- ... 1949561607
**/
const CommandEmpty = forwardRef<
HTMLDivElement,
React.ComponentProps
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState((state) => state.filtered.count === 0);

if (!render) return null;

return (
ref={forwardedRef}
className={cn("py-6 text-center text-sm", className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
});

CommandEmpty.displayName = "CommandEmpty";

const MultipleSelector = React.forwardRef<
MultipleSelectorRef,
MultipleSelectorProps
>(
(
{
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultipleSelectorProps,
ref: React.Ref
) => {
const inputRef = React.useRef(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef(null); // Added this

const [selected, setSelected] = React.useState(value || []);
const [options, setOptions] = React.useState(
transToGroupOption(arrayDefaultOptions, groupBy)
);
const [inputValue, setInputValue] = React.useState("");
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);

React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current as HTMLInputElement,
focus: () => inputRef?.current?.focus(),
reset: () => setSelected([]),
}),
[selected]
);

const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};

const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected]
);

const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
const input = inputRef.current;
if (input) {
if (e.key === "Delete" || e.key === "Backspace") {
if (input.value === "" && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption.fixed) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the field
if (e.key === "Escape") {
input.blur();
}
}
},
[handleUnselect, selected]
);

useEffect(() => {
if (open) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchend", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchend", handleClickOutside);
}

return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchend", handleClickOutside);
};
}, [open]);

useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);

useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);

useEffect(() => {
/** sync search */

const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};

const exec = async () => {
if (!onSearchSync || !open) return;

if (triggerSearchOnFocus) {
doSearchSync();
}

if (debouncedSearchTerm) {
doSearchSync();
}
};

void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);

useEffect(() => {
/** async search */

const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};

const exec = async () => {
if (!onSearch || !open) return;

if (triggerSearchOnFocus) {
await doSearch();
}

if (debouncedSearchTerm) {
await doSearch();
}
};

void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);

const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}

const Item = (
{
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}

);

// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}

// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}

return undefined;
};

const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;

// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (

{emptyIndicator}

);
}

return {emptyIndicator};
}, [creatable, emptyIndicator, onSearch, options]);

const selectables = React.useMemo(
() => removePickedOption(options, selected),
[options, selected]
);

/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}

if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);

return (
{
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn(
"h-auto overflow-visible bg-transparent",
commandProps?.className
)}
shouldFilter={
commandProps?.shouldFilter !== undefined
? commandProps.shouldFilter
: !onSearch
} // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()}
>
{
if (disabled) return;
inputRef?.current?.focus();
}}
>

{selected.map((option) => {
return (

{option.label}
{
if (e.key === "Enter") {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
>



);
})}
{/* Avoid having the "Search" Icon */}
{
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
inputProps?.onFocus?.(event);
}}
placeholder={
hidePlaceholderWhenSelected && selected.length !== 0
? ""
: placeholder
}
className={cn(
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
{
"w-full": hidePlaceholderWhenSelected,
"px-3 py-2": selected.length === 0,
"ml-1": selected.length !== 0,
},
inputProps?.className
)}
/>
{
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
"absolute right-0 h-6 w-6 p-0",
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
"hidden"
)}
>





{open && (
{
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
{loadingIndicator}
) : (

{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && (

)}
{Object.entries(selectables).map(([key, dropdowns]) => (


{dropdowns.map((option) => {
return (
{
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
"cursor-pointer",
option.disable &&
"cursor-default text-muted-foreground"
)}
>
{option.label}

);
})}


))}

)}

)}


);
}
);

MultipleSelector.displayName = "MultipleSelector";
export default MultipleSelector;

< /code>
Несмотря на то, что я ожидаю, что раскрывающийся список будет вести себя как типичное наложение (то есть, плавающее выше контент, а не смену смены), он выглядит встроенным. Как абсолютно позиционированный плавающий элемент?>

Подробнее здесь: https://stackoverflow.com/questions/796 ... ers-inside
Реклама
Ответить Пред. темаСлед. тема

Быстрый ответ

Изменение регистра текста: 
Смайлики
:) :( :oops: :roll: :wink: :muza: :clever: :sorry: :angel: :read: *x)
Ещё смайлики…
   
К этому ответу прикреплено по крайней мере одно вложение.

Если вы не хотите добавлять вложения, оставьте поля пустыми.

Максимально разрешённый размер вложения: 15 МБ.

  • Похожие темы
    Ответы
    Просмотры
    Последнее сообщение

Вернуться в «CSS»