diff --git a/smart-frontend/app/src/components/uikit/CheckAll/CheckAll.tsx b/smart-frontend/app/src/components/uikit/CheckAll/CheckAll.tsx new file mode 100644 index 0000000000..8eb6ba7949 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/CheckAll/CheckAll.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import Checkbox from '@uikit/Checkbox/Checkbox'; + +export interface CheckAllProps { + allList: T[]; + selectedValues: T[] | null; + onChange: (value: T[]) => void; + label?: string; + className?: string; + disabled?: boolean; +} + +const CheckAll = ({ label, allList, selectedValues, onChange, className, disabled }: CheckAllProps) => { + const isAllChecked = useMemo(() => { + if (!selectedValues?.length) return false; + + return allList.length === selectedValues.length; + }, [allList, selectedValues]); + + const handlerAllChanged = (event: React.ChangeEvent) => { + onChange?.(event.target.checked ? allList.map((item) => item) : []); + }; + + return ( + + ); +}; + +export default CheckAll; diff --git a/smart-frontend/app/src/components/uikit/Checkbox/Checkbox.module.scss b/smart-frontend/app/src/components/uikit/Checkbox/Checkbox.module.scss new file mode 100644 index 0000000000..fda992012b --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Checkbox/Checkbox.module.scss @@ -0,0 +1,111 @@ +:global { + body.theme-dark { + --checkbox-border: var(--color-grayUsual); + --checkbox-label-color: var(--checkbox-border); + + --checkbox-checked-border: var(--color-greenSaturated); + --checkbox-checked-label-color: var(--color-offWhite); + + // hover + --checkbox-hover-border: var(--color-greenSaturated); + --checkbox-hover-label-color: var(--color-offWhite); + + // disabled + --checkbox-disabled-border: var(--color-grayReadingOnly); + --checkbox-disabled-label-color: var(--color-grayReadingOnly); + + // readonly + --checkbox-readonly-border: var(--color-grayReadingOnly); + --checkbox-readonly-label-color: var(--color-grayReadingOnly); + } + + body.theme-light { + --checkbox-border: var(--color-ADCM); + --checkbox-label-color: var(--color-ADCM); + + --checkbox-checked-border: var(--color-greenLogo); + --checkbox-checked-label-color: var(--color-grayDarker); + + // hover + --checkbox-hover-border: var(--color-greenLogo); + --checkbox-hover-label-color: var(--color-dark1); + + // disabled + --checkbox-disabled-border: var(--color-lightStrokeDark); + --checkbox-disabled-label-color: var(--color-lightStrokeDark); + + // readonly + --checkbox-readonly-border: var(--color-lightStrokeDark); + --checkbox-readonly-label-color: var(--color-lightStrokeDark); + } +} + +.checkbox { + display: inline-flex; + align-items: center; + cursor: pointer; + position: relative; + + &__input { + width: 20px; + height: 20px; + opacity: 0; + position: absolute; + cursor: inherit; + } + + &__square { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--checkbox-border); + flex-shrink: 0; + color: var(--checkbox-mark-color, var(--checkbox-border)); + border-radius: 3px; + transition: + border-color 250ms, + color 250ms; + } + + &__label { + color: var(--checkbox-label-color); + margin-inline-start: 12px; + transition: color 250ms; + } + + &__input:not(:checked):not(.checkbox_disabled) ~ &__square { + --checkbox-mark-color: transparent !important; + } + + &:not(&_error) &__input:not(:disabled):checked ~ &__square { + --checkbox-border: var(--checkbox-checked-border); + } + + &:not(:hover) &__input:not(:disabled):checked ~ &__label { + --checkbox-label-color: var(--checkbox-checked-label-color); + } + + &:hover { + --checkbox-border: var(--checkbox-hover-border); + --checkbox-label-color: var(--checkbox-hover-label-color); + } + + // &.checkbox_error { + + // } + + &.checkbox_disabled { + --checkbox-border: var(--checkbox-disabled-border); + --checkbox-label-color: var(--checkbox-disabled-label-color); + + cursor: not-allowed !important; + } + + &.checkbox_readonly { + --checkbox-border: var(--checkbox-readonly-border); + --checkbox-label-color: var(--checkbox-readonly-label-color); + cursor: auto !important; + } +} diff --git a/smart-frontend/app/src/components/uikit/Checkbox/Checkbox.stories.tsx b/smart-frontend/app/src/components/uikit/Checkbox/Checkbox.stories.tsx new file mode 100644 index 0000000000..14462f7e5a --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Checkbox from './Checkbox'; + +type Story = StoryObj; +export default { + title: 'uikit/Checkbox', + component: Checkbox, + argTypes: { + disabled: { + description: 'Disabled', + defaultValue: false, + }, + required: { + description: 'Required', + defaultValue: false, + control: 'boolean', + }, + }, +} as Meta; + +export const Checkboxes: Story = { + args: { + disabled: false, + label: 'Label text', + }, + render: (args) => { + return ; + }, +}; diff --git a/smart-frontend/app/src/components/uikit/Checkbox/Checkbox.tsx b/smart-frontend/app/src/components/uikit/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000..4ad6bdf828 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Checkbox/Checkbox.tsx @@ -0,0 +1,47 @@ +import type { InputHTMLAttributes, ReactNode } from 'react'; +import { forwardRef } from 'react'; +import cn from 'classnames'; +import s from './Checkbox.module.scss'; +import Icon from '@uikit/Icon/Icon'; + +interface CheckboxProps extends InputHTMLAttributes { + label?: ReactNode; + readOnly?: boolean; + hasError?: boolean; +} +const Checkbox = forwardRef((props, ref) => { + const { label, checked = false, disabled = false, className, readOnly = false, hasError = false, ...rest } = props; + + const checkboxClasses = cn( + s.checkbox, + { + [s.checkbox_disabled]: disabled, + // technically, we can set readonly and disabled. It's strange but if this case then ignore readonly + [s.checkbox_readonly]: readOnly && !disabled, + [s.checkbox_error]: hasError, + }, + className, + ); + + return ( + + ); +}); + +Checkbox.displayName = 'Checkbox'; + +export default Checkbox; diff --git a/smart-frontend/app/src/components/uikit/ConditionalWrapper/ConditionalWrapper.tsx b/smart-frontend/app/src/components/uikit/ConditionalWrapper/ConditionalWrapper.tsx new file mode 100644 index 0000000000..79782d08de --- /dev/null +++ b/smart-frontend/app/src/components/uikit/ConditionalWrapper/ConditionalWrapper.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +type WrapOrEmptyProps = React.PropsWithChildren & { + Component: React.FC; + isWrap: boolean; +}; + +const ConditionalWrapper = ({ Component, isWrap, children, ...props }: WrapOrEmptyProps) => { + if (isWrap) { + return {children}; + } + return <>{children}; +}; + +export default ConditionalWrapper; diff --git a/smart-frontend/app/src/components/uikit/Icon/icons/check.svg b/smart-frontend/app/src/components/uikit/Icon/icons/check.svg new file mode 100644 index 0000000000..588a0e258d --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Icon/icons/check.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/smart-frontend/app/src/components/uikit/Icon/sprite.ts b/smart-frontend/app/src/components/uikit/Icon/sprite.ts index 5ba52a011c..ca20f471e3 100644 --- a/smart-frontend/app/src/components/uikit/Icon/sprite.ts +++ b/smart-frontend/app/src/components/uikit/Icon/sprite.ts @@ -2,6 +2,7 @@ export const allowIconsNames = [ // 'chevron', 'close', + 'check', 'eye', 'eye-closed', 'logout', diff --git a/smart-frontend/app/src/components/uikit/SearchInput/SearchInput.tsx b/smart-frontend/app/src/components/uikit/SearchInput/SearchInput.tsx new file mode 100644 index 0000000000..5686433cbe --- /dev/null +++ b/smart-frontend/app/src/components/uikit/SearchInput/SearchInput.tsx @@ -0,0 +1,35 @@ +import React, { useRef } from 'react'; +import cn from 'classnames'; +import type { InputProps } from '@uikit/Input/Input'; +import Input from '@uikit/Input/Input'; +import { useForwardRef } from '@hooks/useForwardRef'; +import { createChangeEvent } from '@utils/handlerUtils'; +import IconButton from '@uikit/IconButton/IconButton'; + +const SearchInput = React.forwardRef(({ className, ...props }, ref) => { + const localRef = useRef(null); + const reference = useForwardRef(ref, localRef); + + const handleIconClick = () => { + if (props.value && localRef.current) { + const event = createChangeEvent(localRef.current); + event.target.value = ''; + props.onChange?.(event); + } + }; + + return ( + + } + size="medium" + /> + ); +}); + +SearchInput.displayName = 'SearchInput'; +export default SearchInput; diff --git a/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectField/CommonSelectField.module.scss b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectField/CommonSelectField.module.scss new file mode 100644 index 0000000000..107012c3db --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectField/CommonSelectField.module.scss @@ -0,0 +1,8 @@ +.commonSelectField { + cursor: pointer; + + + &:global(:not(.is-active)) button > svg { + transform: rotate(-90deg); + } +} diff --git a/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectField/CommonSelectField.tsx b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectField/CommonSelectField.tsx new file mode 100644 index 0000000000..66289b7c96 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectField/CommonSelectField.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import type { InputProps } from '@uikit/Input/Input'; +import Input from '@uikit/Input/Input'; +import IconButton from '@uikit/IconButton/IconButton'; +import cn from 'classnames'; +import s from './CommonSelectField.module.scss'; + +type CommonSelectFieldProps = Omit & { + onClick: () => void; + onClear: () => void; + isOpen: boolean; +}; + +const CommonSelectField = React.forwardRef( + ({ className, onClick, onClear, isOpen, hasError, disabled, ...props }, ref) => { + const classes = cn(className, s.commonSelectField, { 'is-active': isOpen }); + + const handleClick = () => { + onClick?.(); + }; + + return ( + <> + + + + ) + } + readOnly={true} + onClick={handleClick} + ref={ref} + hasError={hasError} + disabled={disabled} + /> + + ); + }, +); +export default CommonSelectField; + +CommonSelectField.displayName = 'CommonSelectField'; diff --git a/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectNoResult/CommonSelectNoResult.module.scss b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectNoResult/CommonSelectNoResult.module.scss new file mode 100644 index 0000000000..3e72b7da93 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectNoResult/CommonSelectNoResult.module.scss @@ -0,0 +1,6 @@ +.commonSelectNoResult { + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: var(--color-grayUsual); +} diff --git a/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectNoResult/CommonSelectNoResult.tsx b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectNoResult/CommonSelectNoResult.tsx new file mode 100644 index 0000000000..5130fd25df --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectNoResult/CommonSelectNoResult.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import s from './CommonSelectNoResult.module.scss'; + +const CommonSelectNoResult: React.FC = () => ( +
+ No results found +
+); +export default CommonSelectNoResult; diff --git a/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectSearchFilter/CommonSelectSearchFilter.tsx b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectSearchFilter/CommonSelectSearchFilter.tsx new file mode 100644 index 0000000000..91c2802470 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectSearchFilter/CommonSelectSearchFilter.tsx @@ -0,0 +1,35 @@ +import React, { useState, useEffect } from 'react'; +import { getFilteredOptions } from './CommonSelectSearchFilter.utils'; +import type { SelectOption } from '@uikit/Select/Select.types'; +import SearchInput from '@uikit/SearchInput/SearchInput'; + +interface CommonSelectSearchFilterProps { + originalOptions: SelectOption[]; + setOptions: (list: SelectOption[]) => void; + searchPlaceholder?: string; + className?: string; + onSearch?: (val: string) => void; +} + +const CommonSelectSearchFilter = ({ + originalOptions, + setOptions, + searchPlaceholder, + className, + onSearch, +}: CommonSelectSearchFilterProps) => { + const [search, setSearch] = useState(''); + const handleSearch = (e: React.ChangeEvent) => { + const searchStr = e.target.value; + setSearch(searchStr); + onSearch?.(searchStr); + }; + + useEffect(() => { + setOptions(getFilteredOptions(originalOptions, search)); + }, [originalOptions, search, setOptions]); + + return ; +}; + +export default CommonSelectSearchFilter; diff --git a/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectSearchFilter/CommonSelectSearchFilter.utils.ts b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectSearchFilter/CommonSelectSearchFilter.utils.ts new file mode 100644 index 0000000000..31b997aaff --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/CommonSelect/CommonSelectSearchFilter/CommonSelectSearchFilter.utils.ts @@ -0,0 +1,11 @@ +import type { SelectOption } from '@uikit/Select/Select.types'; + +const defaultCompare = (label: string, search: string) => label.toLowerCase().includes(search.toLowerCase()); + +export const getFilteredOptions = (options: SelectOption[], search: string, comparator = defaultCompare) => { + if (search === '') { + return options; + } + + return options.filter(({ label }) => comparator(label, search)); +}; diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelect.stories.tsx b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelect.stories.tsx new file mode 100644 index 0000000000..78af6f5b3e --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelect.stories.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import MultiSelect from '@uikit/Select/MultiSelect/MultiSelect'; + +type Story = StoryObj; + +export default { + title: 'uikit/Select', + argTypes: { + isSearchable: { + control: { type: 'boolean' }, + }, + checkAllLabel: { + control: { type: 'text' }, + }, + maxHeight: { + control: { type: 'number' }, + }, + placeholder: { + control: { type: 'text' }, + }, + }, + component: MultiSelect, +} as Meta; + +const options = [ + { + value: 123, + label: 'Host 123', + }, + { + value: 234, + label: 'Host 234', + }, + { + value: 345, + label: 'Host 345', + }, + { + value: 456, + label: 'Host 456', + }, + { + value: 567, + label: 'Host 567', + }, + { + value: 678, + label: 'Host 678', + }, + { + value: 789, + label: 'Host 789', + }, +]; + +type MultiSelectExampleProps = { + isSearchable?: boolean; + checkAllLabel?: string; + searchPlaceholder?: string; + maxHeight?: number; + placeholder?: string; +}; + +const MultiSelectExample: React.FC = ({ + isSearchable, + checkAllLabel, + searchPlaceholder, + placeholder, + maxHeight, +}) => { + const [value, setValue] = useState([]); + + return ( +
+ +
+ ); +}; + +export const MultiSelectEasy: Story = { + args: { + isSearchable: false, + checkAllLabel: undefined, + searchPlaceholder: 'Search hosts', + placeholder: 'All hosts', + }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + render: ({ isSearchable, checkAllLabel, searchPlaceholder, maxHeight, placeholder }) => { + return ( + + ); + }, +}; diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelect.tsx b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelect.tsx new file mode 100644 index 0000000000..16740a5c39 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelect.tsx @@ -0,0 +1,99 @@ +import React, { useMemo, useRef, useState } from 'react'; +import type { InputProps } from '@uikit/Input/Input'; +import MultiSelectPanel from './MultiSelectPanel/MultiSelectPanel'; +import Popover from '@uikit/Popover/Popover'; +import type { MultiSelectOptions } from '@uikit/Select/Select.types'; +import { useForwardRef } from '@hooks/useForwardRef'; +import CommonSelectField from '@uikit/Select/CommonSelect/CommonSelectField/CommonSelectField'; +import PopoverPanelDefault from '@uikit/Popover/PopoverPanelDefault/PopoverPanelDefault'; +import type { PopoverOptions } from '@uikit/Popover/Popover.types'; + +export type MultiSelectProps = MultiSelectOptions & + PopoverOptions & + Omit; + +function MultiSelectComponent( + { + options, + value, + onChange, + disabled, + checkAllLabel, + maxHeight, + hasError, + isSearchable, + searchPlaceholder, + containerRef, + placeholder, + placement, + offset, + dependencyWidth = 'min-parent', + ...props + }: MultiSelectProps, + ref: React.ForwardedRef, +) { + const [isOpen, setIsOpen] = useState(false); + const localContainerRef = useRef(null); + const containerReference = useForwardRef(localContainerRef, containerRef); + + const handleChange = (values: T[]) => { + onChange?.(values); + }; + + const selectedOptionLabel = useMemo(() => { + if (value.length === 0) { + return ''; + } + + if (value.length === 1) { + const option = options.find(({ value: val }) => val === value[0]); + return option?.label; + } + + return `${value.length} selected`; + }, [options, value]); + + return ( + <> + setIsOpen((prev) => !prev)} + onClear={() => handleChange([])} + isOpen={isOpen} + value={selectedOptionLabel} + containerRef={containerReference} + hasError={hasError} + disabled={disabled} + placeholder={placeholder} + /> + + + + + + + ); +} + +const MultiSelect = React.forwardRef(MultiSelectComponent) as ( + _props: MultiSelectProps, + _ref: React.ForwardedRef, +) => ReturnType; + +export default MultiSelect; diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectContext/MultiSelect.context.ts b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectContext/MultiSelect.context.ts new file mode 100644 index 0000000000..a8d8315059 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectContext/MultiSelect.context.ts @@ -0,0 +1,21 @@ +import type { MultiSelectOptions, SelectOption } from '@uikit/Select/Select.types'; +import React, { useContext } from 'react'; + +export type MultiSelectContextOptions = MultiSelectOptions & { + setOptions: (list: SelectOption[]) => void; + originalOptions: SelectOption[]; +}; + +export const MultiSelectContext = React.createContext>( + {} as MultiSelectContextOptions, +); + +export const useMultiSelectContext = () => { + const ctx = useContext>( + MultiSelectContext as React.Context>, + ); + if (!ctx) { + throw new Error('useContext must be inside a Provider with a value'); + } + return ctx; +}; diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectContext/MultiSelectContextProvider.tsx b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectContext/MultiSelectContextProvider.tsx new file mode 100644 index 0000000000..1184852bca --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectContext/MultiSelectContextProvider.tsx @@ -0,0 +1,24 @@ +import React, { useState } from 'react'; +import type { MultiSelectOptions } from '@uikit/Select/Select.types'; +import type { MultiSelectContextOptions } from './MultiSelect.context'; +import { MultiSelectContext } from './MultiSelect.context'; + +export const MultiSelectContextProvider = ({ + children, + value, +}: { + children: React.ReactNode; + value: MultiSelectOptions; +}) => { + const originalOptions = value.options; + const [options, setOptions] = useState(originalOptions); + + const contextValue = { + ...value, + options, + setOptions, + originalOptions, + } as MultiSelectContextOptions; + + return {children}; +}; diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectFullCheckAll/MultiSelectFullCheckAll.tsx b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectFullCheckAll/MultiSelectFullCheckAll.tsx new file mode 100644 index 0000000000..69ef2e81f3 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectFullCheckAll/MultiSelectFullCheckAll.tsx @@ -0,0 +1,27 @@ +import { useMemo } from 'react'; +import CheckAll from '@uikit/CheckAll/CheckAll'; +import { useMultiSelectContext } from '../MultiSelectContext/MultiSelect.context'; + +const MultiSelectFullCheckAll = () => { + const { originalOptions, checkAllLabel, onChange, value: selectedValues } = useMultiSelectContext(); + + const allOptionsList = useMemo(() => { + return originalOptions.map(({ value }) => value); + }, [originalOptions]); + + const isDisabledCheckAll = useMemo(() => { + return originalOptions.every(({ disabled }) => disabled); + }, [originalOptions]); + + return ( + + ); +}; + +export default MultiSelectFullCheckAll; diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectList/MultiSelectList.module.scss b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectList/MultiSelectList.module.scss new file mode 100644 index 0000000000..09c40835bf --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectList/MultiSelectList.module.scss @@ -0,0 +1,10 @@ +.multiSelectList { + overflow: auto; + + &__item:not(:last-child) { + margin-bottom: 12px; + } + &__checkbox { + display: flex; + } +} diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectList/MultiSelectList.tsx b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectList/MultiSelectList.tsx new file mode 100644 index 0000000000..e80776040f --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectList/MultiSelectList.tsx @@ -0,0 +1,57 @@ +import type { ChangeEvent } from 'react'; +import React from 'react'; +import { useMultiSelectContext } from '../MultiSelectContext/MultiSelect.context'; +import Checkbox from '@uikit/Checkbox/Checkbox'; +import s from './MultiSelectList.module.scss'; +import cn from 'classnames'; +import ConditionalWrapper from '@uikit/ConditionalWrapper/ConditionalWrapper'; +import Tooltip from '@uikit/Tooltip/Tooltip'; + +const MultiSelectList = () => { + const { + // + options, + value: selectedValues, + onChange, + maxHeight, + } = useMultiSelectContext(); + + const getHandleChange = (value: T) => (e: ChangeEvent) => { + const valueIndex = selectedValues.indexOf(value); + const newSelectedValues = [...selectedValues]; + + if (e.target.checked && valueIndex === -1) { + newSelectedValues.push(value); + } else if (!e.target.checked && valueIndex > -1) { + newSelectedValues.splice(valueIndex, 1); + } + + onChange(newSelectedValues); + }; + + return ( +
    + {options.map(({ value, label, disabled, title }) => ( + +
  • + +
  • +
    + ))} +
+ ); +}; + +export default MultiSelectList; diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectPanel/MultiSelectPanel.module.scss b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectPanel/MultiSelectPanel.module.scss new file mode 100644 index 0000000000..1c797285a7 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectPanel/MultiSelectPanel.module.scss @@ -0,0 +1,28 @@ +:global { + body.theme-dark { + --multiselect-section-border: var(--color-dark1); + } + + body.theme-light { + --multiselect-section-border: var(--color-lightStroke); + } +} + +.multiSelectPanel { + &__section { + padding: 12px; + + &:not(:last-child) { + border-bottom: 1px solid var(--multiselect-section-border); + } + + &_compactMode { + padding: 0; + margin-bottom: 12px; + + &:not(:last-child) { + border-bottom: none; + } + } + } +} diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectPanel/MultiSelectPanel.tsx b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectPanel/MultiSelectPanel.tsx new file mode 100644 index 0000000000..42e7b25224 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectPanel/MultiSelectPanel.tsx @@ -0,0 +1,49 @@ +import CommonSelectNoResult from '@uikit/Select/CommonSelect/CommonSelectNoResult/CommonSelectNoResult'; +import MultiSelectFullCheckAll from '@uikit/Select/MultiSelect/MultiSelectFullCheckAll/MultiSelectFullCheckAll'; +import type { MultiSelectOptions } from '@uikit/Select/Select.types'; +import { MultiSelectContextProvider } from '../MultiSelectContext/MultiSelectContextProvider'; +import { useMultiSelectContext } from '../MultiSelectContext/MultiSelect.context'; +import MultiSelectList from '../MultiSelectList/MultiSelectList'; +import MultiSelectSearchFilter from '../MultiSelectSearchFilter/MultiSelectSearchFilter'; +import s from './MultiSelectPanel.module.scss'; +import cn from 'classnames'; + +const MultiSelectContent = () => { + const { isSearchable, options, checkAllLabel, compactMode } = useMultiSelectContext(); + const isShowOptions = options.length > 0; + const hasCheckAll = !!checkAllLabel; + return ( + <> + {hasCheckAll && ( +
+ +
+ )} +
+ {isSearchable && } +
{isShowOptions ? : }
+
+ + ); +}; + +const MultiSelectPanel = (props: MultiSelectOptions) => { + return ( +
+ + + +
+ ); +}; + +export default MultiSelectPanel; diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectSearchFilter/MultiSelectSearchFilter.module.scss b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectSearchFilter/MultiSelectSearchFilter.module.scss new file mode 100644 index 0000000000..11e6a47465 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectSearchFilter/MultiSelectSearchFilter.module.scss @@ -0,0 +1,10 @@ +.multiSelectSearchFilter { + margin-bottom: 12px; + border-radius: 8px; + display: flex; + gap: 8px; + + &__select { + width: 100%; + } +} diff --git a/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectSearchFilter/MultiSelectSearchFilter.tsx b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectSearchFilter/MultiSelectSearchFilter.tsx new file mode 100644 index 0000000000..02e8c9d332 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/MultiSelect/MultiSelectSearchFilter/MultiSelectSearchFilter.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import { useMultiSelectContext } from '../MultiSelectContext/MultiSelect.context'; +import CommonSelectSearchFilter from '@uikit/Select/CommonSelect/CommonSelectSearchFilter/CommonSelectSearchFilter'; +import s from './MultiSelectSearchFilter.module.scss'; +import Button from '@uikit/Button/Button'; + +const MultiSelectSearchFilter: React.FC = () => { + const { + originalOptions, + options: filteredOptions, + setOptions, + onChange, + searchPlaceholder, + } = useMultiSelectContext(); + + const [search, setSearch] = useState(''); + + const isFilterDisabled = search.length === 0 || filteredOptions.length === 0; + const handleSelectFiltered = () => { + const allFilteredList = filteredOptions.map(({ value }) => value); + onChange(allFilteredList); + }; + + return ( +
+ + +
+ ); +}; + +export default MultiSelectSearchFilter; diff --git a/smart-frontend/app/src/components/uikit/Select/Select.types.ts b/smart-frontend/app/src/components/uikit/Select/Select.types.ts new file mode 100644 index 0000000000..015c455fa0 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/Select.types.ts @@ -0,0 +1,37 @@ +export type SelectValue = string | number | null; + +export interface SelectOption { + value: T; + label: string; + disabled?: boolean; + title?: string; +} + +export interface SingleSelectParams { + options: SelectOption[]; + value: T | null; + onChange: (value: T | null) => void; +} + +interface CommonSelectParams { + maxHeight?: number; + isSearchable?: boolean; + hasError?: boolean; + isDisabled?: boolean; + searchPlaceholder?: string; +} + +export interface SingleSelectOptions extends SingleSelectParams, CommonSelectParams { + noneLabel?: string; +} + +export interface MultiPropsParams { + options: SelectOption[]; + value: T[]; + onChange: (value: T[]) => void; +} + +export interface MultiSelectOptions extends MultiPropsParams, CommonSelectParams { + compactMode?: boolean; + checkAllLabel?: string; +} diff --git a/smart-frontend/app/src/components/uikit/Select/SingleSelect/Select/Select.tsx b/smart-frontend/app/src/components/uikit/Select/SingleSelect/Select/Select.tsx new file mode 100644 index 0000000000..aa041e6901 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/SingleSelect/Select/Select.tsx @@ -0,0 +1,91 @@ +import React, { useMemo, useRef, useState } from 'react'; +import type { InputProps } from '@uikit/Input/Input'; +import SingleSelectPanel from '@uikit/Select/SingleSelect/SingleSelectPanel/SingleSelectPanel'; +import type { SingleSelectOptions } from '@uikit/Select/Select.types'; +import { useForwardRef } from '@hooks/useForwardRef'; +import CommonSelectField from '@uikit/Select/CommonSelect/CommonSelectField/CommonSelectField'; +import PopoverPanelDefault from '@uikit/Popover/PopoverPanelDefault/PopoverPanelDefault'; +import type { PopoverOptions } from '@uikit/Popover/Popover.types'; +import Popover from '@uikit/Popover/Popover'; + +export type SelectProps = SingleSelectOptions & + PopoverOptions & + Omit & { dataTest?: string }; + +function SelectComponent( + { + options, + value, + onChange, + noneLabel, + maxHeight, + isSearchable, + hasError, + disabled, + searchPlaceholder, + containerRef, + placement, + offset, + dependencyWidth = 'min-parent', + dataTest = 'select-popover', + ...props + }: SelectProps, + ref: React.ForwardedRef, +) { + const [isOpen, setIsOpen] = useState(false); + const localContainerRef = useRef(null); + const containerReference = useForwardRef(localContainerRef, containerRef); + + const handleChange = (val: T | null) => { + setIsOpen(false); + onChange?.(val); + }; + + const selectedOptionLabel = useMemo(() => { + const currentOption = options.find(({ value: val }) => val === value); + return currentOption?.label ?? ''; + }, [options, value]); + + return ( + <> + setIsOpen((prev) => !prev)} + onClear={() => handleChange(null)} + isOpen={isOpen} + value={selectedOptionLabel} + containerRef={containerReference} + hasError={hasError} + disabled={disabled} + /> + + + + + + + ); +} + +const Select = React.forwardRef(SelectComponent) as ( + _props: SelectProps, + _ref: React.ForwardedRef, +) => ReturnType; + +export default Select; diff --git a/smart-frontend/app/src/components/uikit/Select/SingleSelect/SingleSelect.stories.tsx b/smart-frontend/app/src/components/uikit/Select/SingleSelect/SingleSelect.stories.tsx new file mode 100644 index 0000000000..c84161c209 --- /dev/null +++ b/smart-frontend/app/src/components/uikit/Select/SingleSelect/SingleSelect.stories.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Select } from '@uikit'; + +type Story = StoryObj; +export default { + title: 'uikit/Select', + argTypes: { + isSearchable: { + control: { type: 'boolean' }, + }, + hasError: { + control: { type: 'boolean' }, + }, + isDisabled: { + control: { type: 'boolean' }, + }, + noneLabel: { + control: { type: 'text' }, + }, + }, + component: Select, +} as Meta; + +const options = [ + { + value: 123, + label: 'A 123', + }, + { + value: 234, + label: 'A 234', + }, + { + value: 345, + label: 'A 345', + }, + { + value: 456, + label: 'A 456', + }, + { + value: 567, + label: 'A 567', + }, + { + value: 678, + label: 'A 678', + }, + { + value: 789, + label: 'A 789', + }, +]; + +type SingleSelectExampleProps = { + isSearchable?: boolean; + isDisabled?: boolean; + hasError?: boolean; + noneLabel?: string; +}; +const SingleSelectExample: React.FC = ({ isSearchable, isDisabled, hasError, noneLabel }) => { + const [value, setValue] = useState(null); + + return ( +
+