diff --git a/client/package.json b/client/package.json index 6b30b9a8e..c68dd17ce 100644 --- a/client/package.json +++ b/client/package.json @@ -37,6 +37,7 @@ "eslint-config-next": "13.4.12", "lodash.debounce": "^4.0.8", "next": "13.4.12", + "qs": "^6.11.2", "react": "18.2.0", "react-content-loader": "^6.2.1", "react-dom": "18.2.0", diff --git a/client/src/shared/ui/input/input/input.module.scss b/client/src/shared/ui/input/input/input.module.scss index 471371de1..155711da1 100644 --- a/client/src/shared/ui/input/input/input.module.scss +++ b/client/src/shared/ui/input/input/input.module.scss @@ -28,17 +28,12 @@ overflow: hidden; background: inherit; padding: 8px 4px; - border-bottom: 1px solid var(--grey-normal-color); caret-color: var(--green-bright-color); color: var(--white-color); transition: all 0.1s ease 0s; font: var(--font-body-weight-m) var(--font-body-size-m) / var(--font-body-line-m) var(--font-rubik); - &:focus { - border-bottom: 1px solid var(--white-color); - } - &:hover:not(:disabled) { background-color: var(--grey-dark-color); } @@ -54,6 +49,14 @@ &__two_icons_end { padding-right: 64px; } + + &_withBorder { + border-bottom: 1px solid var(--grey-normal-color); + + &:focus { + border-bottom: 1px solid var(--white-color); + } + } } .input[type='password']:not(:placeholder-shown) { diff --git a/client/src/shared/ui/input/input/input.tsx b/client/src/shared/ui/input/input/input.tsx index 29af11754..9535b236a 100644 --- a/client/src/shared/ui/input/input/input.tsx +++ b/client/src/shared/ui/input/input/input.tsx @@ -44,6 +44,7 @@ export interface InputProps extends InputHTMLAttributes { maxWidth?: string; subIconPosition?: IconPosition; subIcon?: JSX.Element; + isWithBorder?: boolean; } const InputComponent: ForwardRefRenderFunction = ( @@ -60,6 +61,7 @@ const InputComponent: ForwardRefRenderFunction = ( disabled, subIconPosition = 'end', subIcon, + isWithBorder = true, ...rest } = props; @@ -81,6 +83,7 @@ const InputComponent: ForwardRefRenderFunction = ( [styles.input__icon_start]: subIcon && subIconPosition === 'start', [styles.input__icon_end]: (subIcon && subIconPosition === 'end') || error, [styles.input__two_icons_end]: subIcon && subIconPosition === 'end' && error, + [styles.input__withBorder]: isWithBorder, })} aria-invalid={Boolean(error)} aria-describedby={error ? `${name}-error` : undefined} diff --git a/client/src/shared/ui/select/lib/select-styles.tsx b/client/src/shared/ui/select/lib/select-styles.tsx index 9f270bbe8..6e6eaf805 100644 --- a/client/src/shared/ui/select/lib/select-styles.tsx +++ b/client/src/shared/ui/select/lib/select-styles.tsx @@ -15,10 +15,10 @@ const _colors = { red: '#d42422', }; -const _focusAndActiveStyles = { +const getFocusAndActiveStyles = (isWithBorder?: boolean) => ({ boxShadow: 'none', - borderBottom: `1px solid ${_colors.white}`, -}; + borderBottom: isWithBorder ? `1px solid ${_colors.white}` : 'none', +}); const _checkboxOption = < OptionType, IsMultiType extends boolean = false, @@ -63,11 +63,15 @@ export const selectStyles = < IsMultiType extends boolean = false, GroupType extends GroupBase = GroupBase, >( - isCheckbox = false + styles?: StylesConfig, + isCheckbox = false, + isWithBorder = true ): StylesConfig => { + const customStyles = styles || {}; + return { - control: styles => ({ - ...styles, + control: (base, props) => ({ + ...base, outline: 'none', border: 'none', borderRadius: 0, @@ -77,56 +81,69 @@ export const selectStyles = < cursor: 'text', padding: '8px 4px', fontSize: '100%', - borderBottom: `1px solid ${_colors.grey.normal}`, + borderBottom: isWithBorder ? `1px solid ${_colors.grey.normal}` : 'none', // This line disable the blue border boxShadow: 'none', - ':active': { ..._focusAndActiveStyles }, - ':focus': { ..._focusAndActiveStyles }, - ':focus-within': { ..._focusAndActiveStyles }, + ':active': { ...getFocusAndActiveStyles(isWithBorder) }, + ':focus': { ...getFocusAndActiveStyles(isWithBorder) }, + ':focus-within': { ...getFocusAndActiveStyles(isWithBorder) }, caretColor: _colors.green.bright, + // Spreading custom styles + ...(customStyles.control ? customStyles.control({}, props) : {}), }), - dropdownIndicator: styles => ({ - ...styles, + dropdownIndicator: (base, props) => ({ + ...base, padding: 0, cursor: 'pointer', + ...(customStyles.dropdownIndicator ? customStyles.dropdownIndicator({}, props) : {}), }), - valueContainer: styles => ({ - ...styles, + valueContainer: (base, props) => ({ + ...base, padding: 0, margin: 0, + ...(customStyles.valueContainer ? customStyles.valueContainer({}, props) : {}), }), - singleValue: styles => ({ - ...styles, + singleValue: (base, props) => ({ + ...base, color: _colors.white, padding: 0, margin: 0, + ...(customStyles.singleValue ? customStyles.singleValue({}, props) : {}), }), - input: styles => ({ - ...styles, + input: (base, props) => ({ + ...base, color: _colors.white, padding: 0, margin: 0, + ...(customStyles.input ? customStyles.input({}, props) : {}), }), - menu: () => ({ + menu: (base, props) => ({ + ...base, padding: 0, margin: 0, background: 'transparent', paddingTop: '8px', maxHeight: '300px', + ...(customStyles.menu ? customStyles.menu({}, props) : {}), }), - menuList: styles => ({ - ...styles, + menuList: (base, props) => ({ + ...base, padding: 0, margin: 0, borderRadius: '5px', overflowY: 'auto', boxShadow: '0 4px 24px 0 rgb(17 20 27 / 25%)', background: _colors.grey.dark, + ...(customStyles.menuList ? customStyles.menuList({}, props) : {}), }), option: (styles, props) => - isCheckbox ? _checkboxOption(styles, props) : _regularOption(styles, props), - multiValue: styles => ({ - ...styles, + customStyles.option + ? customStyles.option({}, props) + : isCheckbox + ? _checkboxOption(styles, props) + : _regularOption(styles, props), + multiValue: (base, props) => ({ + ...base, cursor: 'pointer', borderRadius: '5px', padding: '4px 8px', @@ -135,20 +152,23 @@ export const selectStyles = < ':hover': { background: _colors.grey.medium, }, + ...(customStyles.multiValue ? customStyles.multiValue({}, props) : {}), }), - multiValueLabel: styles => ({ - ...styles, + multiValueLabel: (base, props) => ({ + ...base, color: _colors.white, fontSize: '100%', + ...(customStyles.multiValueLabel ? customStyles.multiValueLabel({}, props) : {}), }), - multiValueRemove: styles => ({ - ...styles, + multiValueRemove: (base, props) => ({ + ...base, background: 'none', ':hover': { svg: { fill: _colors.red, }, }, + ...(customStyles.multiValueRemove ? customStyles.multiValueRemove({}, props) : {}), }), }; }; diff --git a/client/src/shared/ui/select/ui/select/select.tsx b/client/src/shared/ui/select/ui/select/select.tsx index dfc2e329f..999f3d44c 100644 --- a/client/src/shared/ui/select/ui/select/select.tsx +++ b/client/src/shared/ui/select/ui/select/select.tsx @@ -20,6 +20,8 @@ import { Option } from './option'; * @prop {string} [label] - Label to display above the select component. * @prop {boolean} [disabled=false] - Specifies if the select should be disabled. * @prop {boolean} [isCheckbox=false] - If true, the options in the select will be presented as checkboxes. + * @prop {boolean} [isWithBorder=true] - If true, then a border will appear under the select. + * @prop {boolean} [isIndicatorAllowed=true] - If true, then there will be a dropdown indicator near the value. * @prop {Option[]} options - Array of options to be displayed in the select. Each option should have a `label` and `value`. * @prop ... and all other props supported by `ReactSelect`. * @@ -54,6 +56,8 @@ interface CustomSelectProps { label?: string; disabled?: boolean; isCheckbox?: boolean; + isWithBorder?: boolean; + isIndicatorAllowed?: boolean; options: Option[]; } @@ -64,7 +68,18 @@ export const Select = < >( props: Props & CustomSelectProps ) => { - const { error, label, disabled, isMulti, isCheckbox = false, name, ...rest } = props; + const { + error, + label, + disabled, + name, + isMulti, + isCheckbox, + isWithBorder = true, + isIndicatorAllowed = true, + styles: customStyles, + ...rest + } = props; return (
(isCheckbox)} + styles={selectStyles(customStyles, isCheckbox, isWithBorder)} name={name} components={{ - DropdownIndicator: error ? ErrorIndicator : DropdownIndicator, + DropdownIndicator: isIndicatorAllowed + ? error + ? ErrorIndicator + : DropdownIndicator + : () => null, IndicatorSeparator: () => null, MultiValueRemove, ...(isCheckbox ? { Option } : {}), // Conditionally include custom Option component diff --git a/client/src/widgets/search/index.tsx b/client/src/widgets/search/index.tsx new file mode 100644 index 000000000..b11c96429 --- /dev/null +++ b/client/src/widgets/search/index.tsx @@ -0,0 +1 @@ +export { SearchBar } from './ui/search-bar'; diff --git a/client/src/widgets/search/lib/hooks/useTrackFiltersArr.ts b/client/src/widgets/search/lib/hooks/useTrackFiltersArr.ts new file mode 100644 index 000000000..56895744c --- /dev/null +++ b/client/src/widgets/search/lib/hooks/useTrackFiltersArr.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef } from 'react'; +import qs from 'qs'; +import { Filter } from '../../types'; + +export const useTrackFiltersArr = ( + filtersArr: Filter[], + callback: (queryString: string) => void +) => { + const timerRef = useRef | null>(null); + useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + const filtersValues = { + filters: filtersArr.reduce<{ [key: string]: string | string[] | [number, number] }>( + (acc, curr) => { + switch (curr.type) { + case 'text': + if (curr.filterValue.length) { + acc[curr.value] = curr.filterValue; + } + + return acc; + + case 'multiple': + case 'checkbox': + if (curr.filterValue.length) { + acc[curr.value] = curr.filterValue.map(item => item.value); + } + + return acc; + + case 'range': + if (curr.filterValue?.length) { + acc[curr.value] = curr.filterValue; + } + + return acc; + } + }, + {} + ), + }; + + const queryString = qs.stringify(filtersValues); + + callback(queryString); + }, 1300); + }, [filtersArr, callback]); +}; diff --git a/client/src/widgets/search/types/index.ts b/client/src/widgets/search/types/index.ts new file mode 100644 index 000000000..fcb073fef --- /dev/null +++ b/client/src/widgets/search/types/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/client/src/widgets/search/types/types.ts b/client/src/widgets/search/types/types.ts new file mode 100644 index 000000000..e692f538f --- /dev/null +++ b/client/src/widgets/search/types/types.ts @@ -0,0 +1,38 @@ +import { MultiValue } from 'react-select'; + +interface IFilter { + label: string; + value: string; + placeholder: string; +} + +interface ITextFilter extends IFilter { + type: 'text'; + filterValue: string; +} + +export interface IOptionItem { + label: string; + value: string; +} + +export interface ICheckboxFilter extends IFilter { + type: 'checkbox'; + optionsArr: IOptionItem[]; + filterValue: MultiValue; +} + +export interface IMultipleFilter extends IFilter { + type: 'multiple'; + optionsArr: IOptionItem[]; + filterValue: MultiValue; +} + +export interface IRangeFilter extends IFilter { + type: 'range'; + min: number; + max: number; + filterValue: null | [number, number]; +} + +export type Filter = ITextFilter | ICheckboxFilter | IMultipleFilter | IRangeFilter; diff --git a/client/src/widgets/search/ui/filter-select/filter-select.module.scss b/client/src/widgets/search/ui/filter-select/filter-select.module.scss new file mode 100644 index 000000000..e32f2c836 --- /dev/null +++ b/client/src/widgets/search/ui/filter-select/filter-select.module.scss @@ -0,0 +1,21 @@ +.container { + min-width: 170px; + border-right: 1px solid var(--green-normal-color); + height: 100%; + transition: all 0.1s ease 0s; + + &:hover { + background-color: var(--grey-dark-color); + } +} + +.select { + &_active { + background-color: var(--grey-dark-color); + } +} + +.control, +.menu { + padding: 0px 8px; +} diff --git a/client/src/widgets/search/ui/filter-select/filter-select.tsx b/client/src/widgets/search/ui/filter-select/filter-select.tsx new file mode 100644 index 000000000..673b85660 --- /dev/null +++ b/client/src/widgets/search/ui/filter-select/filter-select.tsx @@ -0,0 +1,52 @@ +import { FC, useState } from 'react'; +import styles from './filter-select.module.scss'; +import { SingleValue } from 'react-select'; +import { Select } from '@/shared/ui'; +import { Filter } from '../../types'; + +interface FilterSelectProps { + filtersArr: Filter[]; + filterIndex: number; + setFilterIndex: (index: number) => void; +} + +export const FilterSelect: FC = ({ + filtersArr, + filterIndex, + setFilterIndex, +}) => { + const [isMenuOpened, setIsMenuOpened] = useState(false); + + const handleMenuClose = () => { + setIsMenuOpened(false); + }; + + const handleChange = (newValue: SingleValue) => { + if (newValue) { + const index = filtersArr.findIndex((item: Filter) => item.value === newValue.value); + setFilterIndex(index); + } + }; + + return ( +
+ ({ + padding: '8px 0', + }), + }} + classNames={{ + menuList: () => styles.menu_list, + }} + value={value} + controlShouldRenderValue={false} + placeholder={placeholder} + options={optionsArr} + onChange={handleChange} + isWithBorder={false} + isIndicatorAllowed={false} + isCheckbox={isCheckbox} + isMulti + /> + + + + + ); +}; diff --git a/client/src/widgets/search/ui/search-tag-menu/index.ts b/client/src/widgets/search/ui/search-tag-menu/index.ts new file mode 100644 index 000000000..9c85d8334 --- /dev/null +++ b/client/src/widgets/search/ui/search-tag-menu/index.ts @@ -0,0 +1 @@ +export { SearchTagMenu } from './search-tag-menu'; diff --git a/client/src/widgets/search/ui/search-tag-menu/search-tag-menu.module.scss b/client/src/widgets/search/ui/search-tag-menu/search-tag-menu.module.scss new file mode 100644 index 000000000..bea6ec68b --- /dev/null +++ b/client/src/widgets/search/ui/search-tag-menu/search-tag-menu.module.scss @@ -0,0 +1,23 @@ +.container { + border-radius: 5px; + overflow: hidden; +} + +.clear_all_button { + min-width: 0; + box-sizing: border-box; + background: var(--grey-dark-color); + + p { + box-sizing: border-box; + padding: 3px; + padding-left: 6px; + font-size: 100%; + color: var(--red-error-color); + white-space: nowrap; + overflow: hidden; + border-radius: 2px; + text-overflow: ellipsis; + cursor: pointer; + } +} diff --git a/client/src/widgets/search/ui/search-tag-menu/search-tag-menu.tsx b/client/src/widgets/search/ui/search-tag-menu/search-tag-menu.tsx new file mode 100644 index 000000000..8d3c568e7 --- /dev/null +++ b/client/src/widgets/search/ui/search-tag-menu/search-tag-menu.tsx @@ -0,0 +1,60 @@ +import React, { FC, useState } from 'react'; +import styles from './search-tag-menu.module.scss'; +import { ICheckboxFilter, IMultipleFilter, IOptionItem } from '../../types'; +import { useClickOutside } from '@/shared/lib'; +import { Tag } from '../tag'; +import { Flex } from '@/shared/ui'; + +interface SearchTagMenuProps { + filterItem: ICheckboxFilter | IMultipleFilter; + filterIndex: number; + onClearOption: (filterIndex: number, index: number) => void; + onClearAllOptions: (filterIndex: number) => void; +} + +export const SearchTagMenu: FC = ({ + filterItem, + filterIndex, + onClearOption, + onClearAllOptions, +}) => { + const [isListOpened, setIsListOpened] = useState(false); + const filterListRef = useClickOutside(() => setIsListOpened(false)); + + return ( +
setIsListOpened(true)} ref={filterListRef}> + {isListOpened ? ( +
    + {filterItem.filterValue.slice(1).map((item: IOptionItem, index: number) => ( +
  • + onClearOption(filterIndex, index + 1)} + isWithCross + text={item.label} + /> +
  • + ))} +
  • + onClearAllOptions(filterIndex)} + className={styles.clear_all_button} + > +

    Clear All

    +
    +
  • +
+ ) : ( + 2 ? 'items' : 'item' + }`} + /> + )} +
+ ); +}; diff --git a/client/src/widgets/search/ui/tag-list/index.ts b/client/src/widgets/search/ui/tag-list/index.ts new file mode 100644 index 000000000..dfd1a3a07 --- /dev/null +++ b/client/src/widgets/search/ui/tag-list/index.ts @@ -0,0 +1 @@ +export { TagList } from './tag-list'; diff --git a/client/src/widgets/search/ui/tag-list/tag-list.module.scss b/client/src/widgets/search/ui/tag-list/tag-list.module.scss new file mode 100644 index 000000000..ed5e62f5d --- /dev/null +++ b/client/src/widgets/search/ui/tag-list/tag-list.module.scss @@ -0,0 +1,17 @@ +.tag { + &_list { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + &_wrapper { + border-radius: 5px; + overflow: hidden; + } +} + +.checkboxFilterTag { + display: flex; + gap: 8px; +} diff --git a/client/src/widgets/search/ui/tag-list/tag-list.tsx b/client/src/widgets/search/ui/tag-list/tag-list.tsx new file mode 100644 index 000000000..d439ca4f2 --- /dev/null +++ b/client/src/widgets/search/ui/tag-list/tag-list.tsx @@ -0,0 +1,115 @@ +import { FC, Dispatch, SetStateAction } from 'react'; +import styles from './tag-list.module.scss'; +import { Filter } from '../../types'; +import { Tag } from '../tag'; +import { SearchTagMenu } from '../search-tag-menu'; + +interface TagListProps { + filtersArr: Filter[]; + setFilterArr: Dispatch>; +} + +export const TagList: FC = ({ filtersArr, setFilterArr }) => { + if (!filtersArr.length) { + return null; + } + + const handleClearTextFilter = (filterIndex: number) => { + setFilterArr(prev => + prev.map((item, index) => { + if (filterIndex === index) { + item.filterValue = ''; + } + + return item; + }) + ); + }; + + const handleClearMultipleOption = (filterIndex: number, index: number) => { + setFilterArr(prev => { + const filter = prev[filterIndex]; + + if (filter.type === 'checkbox' || filter.type === 'multiple') { + const newFilterValue = filter.filterValue.filter((item, i) => i !== index); + + return prev.map((item, i) => { + if (filterIndex === i) { + item.filterValue = newFilterValue; + } + + return item; + }); + } + + return prev; + }); + }; + + const handleClearAllMultipleOptions = (filterIndex: number) => { + setFilterArr(prev => { + const filter = prev[filterIndex]; + + if (filter.type === 'checkbox' || filter.type === 'multiple') { + const newFilterValue = [filter.filterValue[0]]; + + return prev.map((item, i) => { + if (filterIndex === i) { + item.filterValue = newFilterValue; + } + + return item; + }); + } + + return prev; + }); + }; + + return ( +
    + {filtersArr.map((item, index) => { + switch (item.type) { + case 'text': + return item.filterValue.length ? ( +
  • + handleClearTextFilter(index)} + /> +
  • + ) : null; + + case 'multiple': + case 'checkbox': + if (item.filterValue.length) { + return ( +
  • +
    handleClearMultipleOption(index, 0)} + > + +
    + {item.filterValue.length > 1 && ( + + )} +
  • + ); + } + + return null; + + default: + } + })} +
+ ); +}; diff --git a/client/src/widgets/search/ui/tag/index.ts b/client/src/widgets/search/ui/tag/index.ts new file mode 100644 index 000000000..e533e2003 --- /dev/null +++ b/client/src/widgets/search/ui/tag/index.ts @@ -0,0 +1 @@ +export { Tag } from './tag'; diff --git a/client/src/widgets/search/ui/tag/tag.module.scss b/client/src/widgets/search/ui/tag/tag.module.scss new file mode 100644 index 000000000..da402ee92 --- /dev/null +++ b/client/src/widgets/search/ui/tag/tag.module.scss @@ -0,0 +1,42 @@ +.tag { + min-width: 0; + box-sizing: border-box; + height: fit-content; + padding: 4px 8px; + background: var(--grey-dark-color); + cursor: pointer; + + &:hover { + .remove { + svg { + fill: var(--red-error-color); + } + } + } +} + +.text { + box-sizing: border-box; + padding: 3px; + padding-left: 6px; + font-size: 100%; + color: var(--white-color); + white-space: nowrap; + overflow: hidden; + border-radius: 2px; + text-overflow: ellipsis; + + &_with_hover { + &:hover { + color: var(--green-bright-color); + } + } +} + +.remove { + box-sizing: border-box; + padding-left: 4px; + padding-right: 4px; + border-radius: 2px; + background: none; +} diff --git a/client/src/widgets/search/ui/tag/tag.tsx b/client/src/widgets/search/ui/tag/tag.tsx new file mode 100644 index 000000000..0ddfe5525 --- /dev/null +++ b/client/src/widgets/search/ui/tag/tag.tsx @@ -0,0 +1,38 @@ +import { FC } from 'react'; +import styles from './tag.module.scss'; +import { X } from '@/shared/assets'; +import { Flex, Typography } from '@/shared/ui'; +import clsx from 'clsx'; + +interface TagProps { + text: string; + isWithCross?: boolean; + isFilledWhileHover?: boolean; + onClick?: () => void; +} + +export const Tag: FC = ({ + text, + onClick, + isWithCross = false, + isFilledWhileHover = false, +}) => { + return ( + + + {text} + + {isWithCross && ( + + + + )} + + ); +}; diff --git a/client/src/widgets/search/ui/text-input/index.ts b/client/src/widgets/search/ui/text-input/index.ts new file mode 100644 index 000000000..448742091 --- /dev/null +++ b/client/src/widgets/search/ui/text-input/index.ts @@ -0,0 +1 @@ +export { TextInput } from './text-input'; diff --git a/client/src/widgets/search/ui/text-input/text-input.module.scss b/client/src/widgets/search/ui/text-input/text-input.module.scss new file mode 100644 index 000000000..1f166ab8f --- /dev/null +++ b/client/src/widgets/search/ui/text-input/text-input.module.scss @@ -0,0 +1,13 @@ +.container { + width: 100%; + transition: all 0.1s ease 0s; + padding-left: 2px; + + &:hover { + background-color: var(--grey-dark-color); + } +} + +.search_icon { + margin: auto 12px auto 4px; +} diff --git a/client/src/widgets/search/ui/text-input/text-input.tsx b/client/src/widgets/search/ui/text-input/text-input.tsx new file mode 100644 index 000000000..2cbdc3ba1 --- /dev/null +++ b/client/src/widgets/search/ui/text-input/text-input.tsx @@ -0,0 +1,37 @@ +import { FC, useEffect, useState } from 'react'; +import styles from './text-input.module.scss'; +import { Search } from '@/shared/assets'; +import { Flex, Input } from '@/shared/ui'; + +interface TextInputProps { + placeholder: string; + defaultValue: string; + onChange: (value: string) => void; +} + +export const TextInput: FC = ({ defaultValue, placeholder, onChange }) => { + const [value, setValue] = useState(defaultValue); + + const handleChange = (value: string) => { + setValue(value); + onChange(value); + }; + + useEffect(() => { + setValue(defaultValue); + }, [defaultValue]); + + return ( + + handleChange(e.target.value)} + placeholder={placeholder} + isWithBorder={false} + /> + + + + + ); +}; diff --git a/client/yarn.lock b/client/yarn.lock index 84c344909..15b51c667 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -7701,6 +7701,7 @@ __metadata: lodash.debounce: ^4.0.8 next: 13.4.12 prettier: ^3.0.1 + qs: ^6.11.2 react: 18.2.0 react-content-loader: ^6.2.1 react-dom: 18.2.0