Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(new-hope): add react hook form support in combobox #1552

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import React, { forwardRef, useState, useReducer, useMemo, createContext, useLayoutEffect, useRef } from 'react';
import type { ChangeEvent } from 'react';
import React, {
forwardRef,
useState,
useReducer,
useMemo,
createContext,
useLayoutEffect,
useRef,
useEffect,
} from 'react';
import type { ChangeEvent, ForwardedRef } from 'react';
import { safeUseId, useForkRef } from '@salutejs/plasma-core';

import { RootProps } from '../../../engines';
Expand All @@ -26,19 +35,23 @@ import { Ul, base, StyledArrow, IconArrowWrapper, StyledEmptyState } from './Com
import type { ItemContext, ComboboxProps } from './Combobox.types';
import { base as viewCSS } from './variations/_view/base';
import { base as sizeCSS } from './variations/_size/base';
import type { ItemOptionTransformed } from './ui/Inner/ui/Item/Item.types';
import type { ItemOption, ItemOptionTransformed } from './ui/Inner/ui/Item/Item.types';
import { SelectNative } from './ui/SelectNative/SelectNative';

export const Context = createContext<ItemContext>({} as ItemContext);

/**
* Поле ввода с выпадающим списком и возможностью фильтрации и выбора элементов.
*/

export const comboboxRoot = (Root: RootProps<HTMLInputElement, Omit<ComboboxProps, 'items'>>) =>
forwardRef<HTMLInputElement, ComboboxProps>((props, ref) => {
const {
name,
multiple,
value: outerValue,
onChange: outerOnChange,
defaultValue,
isTargetAmount,
targetAmount,
items,
Expand Down Expand Up @@ -67,6 +80,7 @@ export const comboboxRoot = (Root: RootProps<HTMLInputElement, Omit<ComboboxProp
renderValue,
...rest
} = props;

const transformedItems = useMemo(() => initialItemsTransform(items || []), [items]);

// Создаем структуры для быстрой работы с деревом
Expand Down Expand Up @@ -127,12 +141,26 @@ export const comboboxRoot = (Root: RootProps<HTMLInputElement, Omit<ComboboxProp
}
}, floatingPopoverRef);

const onChange = (newValue: string | Array<string>) => {
// Эта функция срабатывает при изменении Combobox и
// при изменении нативного Select для формы (срабатывает только после изменения internalValue и рендера).
const onChange = (newValue: string | Array<string> | ChangeEvent<HTMLSelectElement> | null) => {
// Условие для отправки изменений наружу
if (outerOnChange) {
outerOnChange(newValue as any);
// Условие для отправки если комбобокс используется без формы.
if (!name && (typeof newValue === 'string' || Array.isArray(newValue))) {
outerOnChange(newValue as any);
}

// Условие для отправки если комбобокс используется с формой.
if (name && typeof newValue === 'object' && !Array.isArray(newValue)) {
outerOnChange(newValue as any);
}
}

setInternalValue(newValue);
// Условие для изменения внутреннего значения (только если newValue строка или массив строк).
if (typeof newValue === 'string' || Array.isArray(newValue)) {
setInternalValue(newValue);
}
};

const handleClickArrow = () => {
Expand Down Expand Up @@ -337,8 +365,32 @@ export const comboboxRoot = (Root: RootProps<HTMLInputElement, Omit<ComboboxProp
// А переменную, содержащую сложные типы данных, нельзя помещать в deps.
}, [outerValue, internalValue, items]);

useEffect(() => {
if (defaultValue) {
setInternalValue(defaultValue);
}
}, [defaultValue]);

return (
<Root size={size} view={view} labelPlacement={labelPlacement} disabled={disabled} readOnly={readOnly}>
<Root
size={size}
view={view}
labelPlacement={labelPlacement}
disabled={disabled}
readOnly={readOnly}
name={name}
>
{name && (
<SelectNative
items={valueToCheckedMap}
name={name}
value={internalValue}
multiple={multiple}
onChange={onChange}
onSetValue={setInternalValue}
ref={ref as ForwardedRef<HTMLInputElement>}
/>
)}
<div>
<Context.Provider
value={{
Expand All @@ -362,7 +414,7 @@ export const comboboxRoot = (Root: RootProps<HTMLInputElement, Omit<ComboboxProp
listWidth={listWidth}
target={(referenceRef) => (
<StyledTextField
ref={inputForkRef}
ref={name ? inputRef : (inputForkRef as ForwardedRef<HTMLInputElement>)}
inputWrapperRef={referenceRef}
value={textValue}
onChange={handleTextValueChange}
Expand Down Expand Up @@ -413,6 +465,7 @@ export const comboboxRoot = (Root: RootProps<HTMLInputElement, Omit<ComboboxProp
labelPlacement={labelPlacement}
disabled={disabled}
readOnly={readOnly}
name={name}
>
<Ul
role="tree"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CSSProperties, ButtonHTMLAttributes } from 'react';
import type { CSSProperties, ButtonHTMLAttributes, ChangeEventHandler } from 'react';
import React from 'react';

import { RequiredProps, LabelProps } from '../../TextField/TextField.types';
Expand All @@ -22,33 +22,67 @@ type Placement =
| 'left-end';

type IsMultiselect<T extends ItemOption = ItemOption> =
| {
multiple?: false;
value?: string;
onChange?: (value: string) => void;
/**
* Если включено - будет выведено общее количество выбранных элементов вместо перечисления.
* @default false
*/
isTargetAmount?: never | false;
/**
* Ручная настройка количества выбранных элементов. Только при isTargetAmount === true.
* @default undefined
*/
targetAmount?: never;
renderValue?: never;
}
| {
multiple: true;
value?: Array<string>;
onChange?: (value: Array<string>) => void;
isTargetAmount?: true;
targetAmount?: number;
/**
* Callback для кастомной настройки значения в селекте.
*/
renderValue?: (item: T) => string;
};
| ({ name?: never; defaultValue?: never } & (
| {
multiple?: false;
value?: string;
onChange?: (value: string) => void;
/**
* Если включено - будет выведено общее количество выбранных элементов вместо перечисления.
* @default false
*/
isTargetAmount?: never | false;
/**
* Ручная настройка количества выбранных элементов. Только при isTargetAmount === true.
* @default undefined
*/
targetAmount?: never;
/**
* Callback для кастомной настройки значения в селекте.
*/
renderValue?: never;
}
| {
multiple: true;
value?: string[];
onChange?: (value: string[]) => void;
isTargetAmount?: true;
targetAmount?: number;
renderValue?: (item: T) => string;
}
))
| ({ name: string; onChange?: ChangeEventHandler } & (
| {
multiple?: false;
defaultValue?: string;
value?: never;
isTargetAmount?: never | false;
targetAmount?: never;
renderValue?: never;
}
| {
multiple: true;
defaultValue?: string[];
value?: never;
isTargetAmount?: true;
targetAmount?: number;
renderValue?: (item: T) => string;
}
));

// type VS = (value: string) => void;
// type VSA = (value: string[]) => void;

// type IsMultiselect<T extends ItemOption = ItemOption> = {
// name?: string;
// multiple?: boolean;
// value?: string | string[];
// defaultValue?: string | string[];
// onChange?: VS | VSA | ChangeEventHandler;
// isTargetAmount?: boolean;
// targetAmount?: number;
// renderValue?: (item: T) => string;
// };

type ViewStateProps =
| {
Expand Down Expand Up @@ -176,7 +210,7 @@ export type ComboboxProps<T extends ItemOption = ItemOption> = BasicProps<T> &
ViewStateProps &
IsMultiselect<T> &
RequiredProps &
Omit<ButtonHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'>;
Omit<ButtonHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'name' | 'defaultValue'>;

export type FloatingPopoverProps = {
target: React.ReactNode | ((ref: React.MutableRefObject<HTMLElement | null>) => React.ReactNode);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { styled } from '@linaria/react';

import { applyHidden } from '../../../../../mixins';

export const SelectHidden = styled.select`
${applyHidden()};
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { ChangeEvent, forwardRef, useEffect, useRef } from 'react';
import { useForkRef } from '@salutejs/plasma-core';

import { createEvent } from '../../../../../utils';
import { ComboboxProps } from '../../Combobox.types';
import { ValueToCheckedMapType } from '../../hooks/getPathMaps';

import { SelectHidden } from './SelectNative.styles';

type Props = Pick<ComboboxProps, 'name' | 'value' | 'multiple'> & {
onChange: (value: ChangeEvent<HTMLSelectElement> | null) => void;
onSetValue: (value: string) => void;
items: ValueToCheckedMapType;
};

export const SelectNative = forwardRef<HTMLInputElement, Props>(
({ name, multiple, items, value, onChange, onSetValue }, ref) => {
const values = (multiple ? value : [value]) as string[];
const selectRef = useRef<HTMLSelectElement>(null);
const forkRef = useForkRef(selectRef, ref as any);
const options = Array.from(items.keys());

useEffect(() => {
const event = createEvent(selectRef);
if (onChange) {
onChange(event);
}
}, [values]);

useEffect(() => {
if (selectRef.current) {
const valueInit = selectRef.current.value;
const item = options.find((v) => v === valueInit);

if (item) {
onSetValue(item);
}
}
}, [selectRef]);

return (
<>
<SelectHidden
ref={forkRef}
multiple={multiple}
name={name}
hidden
value={multiple ? values : values[0]}
>
{options.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</SelectHidden>
</>
);
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,6 @@ const items = [

const SingleStory = (args: StorySelectProps) => {
const [value, setValue] = useState('');

return (
<div style={{ width: '400px' }}>
<Combobox
Expand Down
44 changes: 44 additions & 0 deletions packages/plasma-new-hope/src/utils/createEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { RefObject } from 'react';

export const createEvent = <T extends HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement>(
ref: RefObject<T>,
) => {
if (ref.current) {
const event = new Event('change', { bubbles: true });
Object.defineProperty(event, 'target', { writable: false, value: ref.current });
const syntheticEvent = createSyntheticEvent(event) as React.ChangeEvent<typeof ref.current>;
return syntheticEvent;
}

return null;
};

const createSyntheticEvent = <T extends Element, E extends Event>(event: E): React.SyntheticEvent<T, E> => {
let isDefaultPrevented = false;
let isPropagationStopped = false;
const preventDefault = () => {
isDefaultPrevented = true;
event.preventDefault();
};
const stopPropagation = () => {
isPropagationStopped = true;
event.stopPropagation();
};
return {
nativeEvent: event,
currentTarget: event.currentTarget as EventTarget & T,
target: event.target as EventTarget & T,
bubbles: event.bubbles,
cancelable: event.cancelable,
defaultPrevented: event.defaultPrevented,
eventPhase: event.eventPhase,
isTrusted: event.isTrusted,
preventDefault,
isDefaultPrevented: () => isDefaultPrevented,
stopPropagation,
isPropagationStopped: () => isPropagationStopped,
persist: () => {},
timeStamp: event.timeStamp,
type: event.type,
};
};
1 change: 1 addition & 0 deletions packages/plasma-new-hope/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { IS_REACT_18, safeUseId } from './react';
export { isNumber } from './isNumber';
export { mergeRefs, setRefList } from './setRefList';
export { isEmpty } from './isEmpty';
export { createEvent } from './createEvent';
export * as constants from './constants';
export * from './getPopoverPlacement';
export { noop } from './noop';
Expand Down
Loading
Loading