diff --git a/src/bundle/Resources/config/bazinga_js_translation.yaml b/src/bundle/Resources/config/bazinga_js_translation.yaml index 762dbc83ef..ad62c5d39e 100644 --- a/src/bundle/Resources/config/bazinga_js_translation.yaml +++ b/src/bundle/Resources/config/bazinga_js_translation.yaml @@ -12,4 +12,5 @@ active_domains: - 'ibexa_user_invitation' - 'ibexa_content_type' - 'ibexa_dropdown' + - 'ibexa_popup_menu' - 'messages' diff --git a/src/bundle/Resources/public/js/scripts/admin.input.text.js b/src/bundle/Resources/public/js/scripts/admin.input.text.js index 826c294855..45e240176d 100644 --- a/src/bundle/Resources/public/js/scripts/admin.input.text.js +++ b/src/bundle/Resources/public/js/scripts/admin.input.text.js @@ -1,4 +1,5 @@ (function (global, doc) { + const INPUT_PADDING = 12; const togglePasswordVisibility = (event) => { const passwordTogglerBtn = event.currentTarget; const passwordShowIcon = passwordTogglerBtn.querySelector('.ibexa-input-text-wrapper__password-show'); @@ -40,35 +41,27 @@ passwordTogglerBtns.forEach((passwordTogglerBtn) => passwordTogglerBtn.addEventListener('click', togglePasswordVisibility, false)); recalculateStyling(); }; - const handleInputChange = ({ target: { value } }, btn) => { - btn.disabled = value.trim() === ''; - }; - const recalculateStyling = () => { - const extraBtns = doc.querySelectorAll('.ibexa-input-text-wrapper__action-btn--extra-btn'); - - extraBtns.forEach((btn) => { - const input = btn.closest('.ibexa-input-text-wrapper').querySelector('input'); - const clearButton = btn.previousElementSibling?.classList.contains('ibexa-input-text-wrapper__action-btn--clear') - ? btn.previousElementSibling - : null; + const recalculateInputStyling = (inputActionsContainer) => { + const input = inputActionsContainer.closest('.ibexa-input-text-wrapper').querySelector('input'); - if (!input) { - return; - } + if (!input) { + return; + } - btn.disabled = !input.value; - input.addEventListener('input', (inputEvent) => handleInputChange(inputEvent, btn), false); + const { width: actionsWidth } = inputActionsContainer.getBoundingClientRect(); - if (!clearButton) { - return; - } + input.style.paddingRight = `${actionsWidth + INPUT_PADDING}px`; + }; + const recalculateStyling = () => { + const inputActionsContainers = doc.querySelectorAll('.ibexa-input-text-wrapper__actions'); - const clearButtonStyles = global.getComputedStyle(clearButton); - const clearButtonMarginRight = parseInt(clearButtonStyles.getPropertyValue('margin-right'), 10); - const clearButtonWidth = parseInt(clearButtonStyles.getPropertyValue('width'), 10); - const paddingRight = `${btn.offsetWidth + clearButtonMarginRight + clearButtonWidth}px`; + inputActionsContainers.forEach((inputActionsContainer) => { + const inputActionsContainerObserver = new ResizeObserver(() => { + recalculateInputStyling(inputActionsContainer); + }); - input.style.paddingRight = paddingRight; + inputActionsContainerObserver.observe(inputActionsContainer); + recalculateInputStyling(inputActionsContainer); }); }; diff --git a/src/bundle/Resources/public/js/scripts/helpers/config.loader.js b/src/bundle/Resources/public/js/scripts/helpers/config.loader.js index 2c2f3ff9b2..821121f1bf 100644 --- a/src/bundle/Resources/public/js/scripts/helpers/config.loader.js +++ b/src/bundle/Resources/public/js/scripts/helpers/config.loader.js @@ -12,6 +12,7 @@ import * as modal from './modal.helper'; import * as notification from './notification.helper'; import * as objectInstances from './object.instances'; import * as pagination from './pagination.helper'; +import * as react from './react.helper'; import * as request from './request.helper'; import * as system from './system.helper'; import * as table from './table.helper'; @@ -36,6 +37,7 @@ import * as user from './user.helper'; ibexa.addConfig('helpers.notification', notification); ibexa.addConfig('helpers.objectInstances', objectInstances); ibexa.addConfig('helpers.pagination', pagination); + ibexa.addConfig('helpers.react', react); ibexa.addConfig('helpers.request', request); ibexa.addConfig('helpers.system', system); ibexa.addConfig('helpers.table', table); diff --git a/src/bundle/Resources/public/js/scripts/helpers/react.helper.js b/src/bundle/Resources/public/js/scripts/helpers/react.helper.js new file mode 100644 index 0000000000..2399378631 --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/helpers/react.helper.js @@ -0,0 +1,30 @@ +import { getRootDOMElement } from './context.helper'; + +const createDynamicRoot = ({ contextDOMElement = getRootDOMElement(), id } = {}) => { + if (id && window.document.getElementById(id) !== null) { + console.warn(`You're creating second root element with ID "${id}". IDs should be unique inside a document.`); + } + + const rootDOMElement = document.createElement('div'); + + rootDOMElement.classList.add('ibexa-react-root'); + + if (id) { + rootDOMElement.id = id; + } + + contextDOMElement.appendChild(rootDOMElement); + + const reactRoot = window.ReactDOM.createRoot(rootDOMElement); + + return reactRoot; +}; + +const removeDynamicRoot = (reactRoot) => { + const rootDOMElement = reactRoot._internalRoot?.containerInfo; + + reactRoot.unmount(); + rootDOMElement?.remove(); +}; + +export { createDynamicRoot, removeDynamicRoot }; diff --git a/src/bundle/Resources/public/scss/ui/modules/_common.scss b/src/bundle/Resources/public/scss/ui/modules/_common.scss index d916cc360d..e9eee25a80 100644 --- a/src/bundle/Resources/public/scss/ui/modules/_common.scss +++ b/src/bundle/Resources/public/scss/ui/modules/_common.scss @@ -5,3 +5,5 @@ @import 'common/user.name'; @import 'common/taggify'; @import 'common/spinner'; +@import 'common/draggable.dialog'; +@import 'common/popup.menu'; diff --git a/src/bundle/Resources/public/scss/ui/modules/common/_draggable.dialog.scss b/src/bundle/Resources/public/scss/ui/modules/common/_draggable.dialog.scss new file mode 100644 index 0000000000..9398d4a219 --- /dev/null +++ b/src/bundle/Resources/public/scss/ui/modules/common/_draggable.dialog.scss @@ -0,0 +1,13 @@ +.c-draggable-dialog { + position: fixed; + z-index: 10000; + + &--hidden { + visibility: hidden; + } + + &__draggable { + cursor: grab; + user-select: none; + } +} diff --git a/src/bundle/Resources/public/scss/ui/modules/common/_popup.menu.scss b/src/bundle/Resources/public/scss/ui/modules/common/_popup.menu.scss new file mode 100644 index 0000000000..09427e8b16 --- /dev/null +++ b/src/bundle/Resources/public/scss/ui/modules/common/_popup.menu.scss @@ -0,0 +1,74 @@ +.c-popup-menu { + display: flex; + flex-direction: column; + gap: calculateRem(1px); + padding: calculateRem(8px) 0; + background: $ibexa-color-white; + border: calculateRem(1px) solid $ibexa-color-light; + border-radius: $ibexa-border-radius; + box-shadow: calculateRem(4px) calculateRem(22px) calculateRem(67px) 0 rgba($ibexa-color-info, 0.2); + position: fixed; + z-index: 1060; + + &--hidden { + visibility: hidden; + } + + &__search { + margin-bottom: calculateRem(4px); + padding: 0 calculateRem(8px); + + &--hidden { + display: none; + } + } + + &__search-input { + border-radius: $ibexa-border-radius; + } + + &__groups { + max-height: calculateRem(390px); + overflow-y: auto; + } + + &__group:not(:last-child) { + &::after { + content: ''; + border-top: calculateRem(1px) solid $ibexa-color-light; + display: flex; + width: calc(100% - calculateRem(16px)); + margin: calculateRem(1px) calculateRem(8px) 0; + } + } + + &__item { + display: flex; + align-items: center; + min-width: calculateRem(150px); + padding: 0 calculateRem(8px); + transition: all $ibexa-admin-transition-duration $ibexa-admin-transition; + } + + &__item-content { + position: relative; + display: flex; + align-items: baseline; + width: 100%; + cursor: pointer; + padding: calculateRem(9px); + color: $ibexa-color-dark; + font-size: $ibexa-text-font-size-medium; + text-align: left; + text-decoration: none; + border: none; + border-radius: $ibexa-border-radius; + transition: all $ibexa-admin-transition-duration $ibexa-admin-transition; + + &:hover { + background-color: $ibexa-color-light-300; + color: $ibexa-color-black; + text-decoration: none; + } + } +} diff --git a/src/bundle/Resources/translations/ibexa_popup_menu.en.xliff b/src/bundle/Resources/translations/ibexa_popup_menu.en.xliff new file mode 100644 index 0000000000..02053674b7 --- /dev/null +++ b/src/bundle/Resources/translations/ibexa_popup_menu.en.xliff @@ -0,0 +1,16 @@ + + + +
+ + The source node in most cases contains the sample message as written by the developer. If it looks like a dot-delimitted string such as "form.label.firstname", then the developer has not provided a default message. +
+ + + Search... + Search... + key: ibexa_popup_menu.search.placeholder + + +
+
diff --git a/src/bundle/Resources/views/themes/admin/content/form_fields.html.twig b/src/bundle/Resources/views/themes/admin/content/form_fields.html.twig index fa32def56b..d19c08ef6f 100644 --- a/src/bundle/Resources/views/themes/admin/content/form_fields.html.twig +++ b/src/bundle/Resources/views/themes/admin/content/form_fields.html.twig @@ -68,7 +68,7 @@ {{- block('form_label') }} - {{- form_widget(form, {'attr': attr}) -}} + {{- form_widget(form, { attr }) -}}
{{- block('form_errors') -}} diff --git a/src/bundle/Resources/views/themes/admin/ui/form_fields.html.twig b/src/bundle/Resources/views/themes/admin/ui/form_fields.html.twig index cc7adcf1ef..073a8ead4b 100644 --- a/src/bundle/Resources/views/themes/admin/ui/form_fields.html.twig +++ b/src/bundle/Resources/views/themes/admin/ui/form_fields.html.twig @@ -389,6 +389,9 @@ {%- set type = type|default('text') -%} {%- set is_text_input = type == 'text' or type == 'number' or force_text|default(false) -%} {%- if is_text_input -%} + {# @deprecated extra_actions_after in attr will be removed in 5.0, used for BC in 4.6 #} + {%- set extra_actions_after_from_attr = attr.extra_actions_after|default(null) -%} + {%- set attr = attr|filter((value, key) => key != 'extra_actions_after') -%} {%- set attr = attr|merge({class: (attr.class|default('') ~ ' ibexa-input ibexa-input--text')|trim}) -%} {%- set empty_placeholder_for_hiding_clear_btn_with_css = ' ' -%} {%- set attr = attr|merge({placeholder: (attr.placeholder is defined and attr.placeholder is not null) ? attr.placeholder : empty_placeholder_for_hiding_clear_btn_with_css}) -%} @@ -399,6 +402,11 @@ {% block content %} {{ input_html }} {% endblock %} + + {% block actions %} + {{ parent() }} + {{ extra_actions_after|default(extra_actions_after_from_attr)}} + {% endblock %} {%- endembed -%} {%- else -%} {{ parent() }} diff --git a/src/bundle/ui-dev/src/modules/common/draggable-dialog/draggable.dialog.js b/src/bundle/ui-dev/src/modules/common/draggable-dialog/draggable.dialog.js new file mode 100644 index 0000000000..7dc3583f00 --- /dev/null +++ b/src/bundle/ui-dev/src/modules/common/draggable-dialog/draggable.dialog.js @@ -0,0 +1,157 @@ +import React, { useRef, createContext, useState, useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { getRootDOMElement } from '@ibexa-admin-ui/src/bundle/Resources/public/js/scripts/helpers/context.helper'; +import { createCssClassNames } from '@ibexa-admin-ui/src/bundle/ui-dev/src/modules/common/helpers/css.class.names'; + +export const DraggableContext = createContext(); + +const DraggableDialog = ({ children, referenceElement, positionOffset }) => { + const rootDOMElement = getRootDOMElement(); + const containerRef = useRef(); + const dragOffsetPosition = useRef({ x: 0, y: 0 }); + const containerSize = useRef({ width: 0, height: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [coords, setCoords] = useState({ x: null, y: null }); + const dialogClasses = createCssClassNames({ + 'c-draggable-dialog': true, + 'c-draggable-dialog--hidden': coords.x === null || coords.y === null, + }); + const containerAttrs = { + ref: containerRef, + className: dialogClasses, + style: { + top: coords.y, + left: coords.x, + }, + }; + const getMousePosition = useCallback((event) => ({ x: event.x, y: event.y }), []); + const setContainerCoords = useCallback( + (event) => { + const mouseCoords = getMousePosition(event); + let x = mouseCoords.x - dragOffsetPosition.current.x; + let y = mouseCoords.y - dragOffsetPosition.current.y; + let newDragOffsetX; + let newDragOffsetY; + + if (x < 0) { + x = 0; + newDragOffsetX = mouseCoords.x; + } else if (x + containerSize.current.width > window.innerWidth) { + x = window.innerWidth - containerSize.current.width; + newDragOffsetX = mouseCoords.x - x; + } + + if (y < 0) { + y = 0; + newDragOffsetY = mouseCoords.y; + } else if (y + containerSize.current.height > window.innerHeight) { + y = window.innerHeight - containerSize.current.height; + newDragOffsetY = mouseCoords.y - y; + } + + if (newDragOffsetX) { + dragOffsetPosition.current.x = newDragOffsetX; + } + + if (newDragOffsetY) { + dragOffsetPosition.current.y = newDragOffsetY; + } + + setCoords({ + x, + y, + }); + }, + [getMousePosition], + ); + const startDragging = (event) => { + const { x: containerX, y: containerY, width, height } = containerRef.current.getBoundingClientRect(); + const mouseCoords = getMousePosition(event.nativeEvent); + + dragOffsetPosition.current = { + x: mouseCoords.x - containerX, + y: mouseCoords.y - containerY, + }; + + containerSize.current = { + width, + height, + }; + + setContainerCoords(event.nativeEvent); + + setIsDragging(true); + }; + const stopDragging = useCallback(() => { + setIsDragging(false); + }, []); + const handleDragging = useCallback( + (event) => { + setContainerCoords(event); + }, + [setContainerCoords], + ); + + useEffect(() => { + if (!isDragging) { + return; + } + + rootDOMElement.addEventListener('mousemove', handleDragging, false); + rootDOMElement.addEventListener('mouseup', stopDragging, false); + + return () => { + rootDOMElement.removeEventListener('mousemove', handleDragging); + rootDOMElement.removeEventListener('mouseup', stopDragging); + }; + }, [isDragging, rootDOMElement, handleDragging, stopDragging]); + + useEffect(() => { + const { top: referenceTop, left: referenceLeft } = referenceElement.getBoundingClientRect(); + const { width: containerWidth, height: containerHeight } = containerRef.current.getBoundingClientRect(); + const { x: offsetX, y: offsetY } = positionOffset(referenceElement); + let x = referenceLeft + offsetX; + let y = referenceTop + offsetY; + + if (x < 0) { + x = 0; + } else if (x + containerWidth > window.innerWidth) { + x = window.innerWidth - containerWidth; + } + + if (y < 0) { + y = 0; + } else if (y + containerHeight > window.innerHeight) { + y = window.innerHeight - containerHeight; + } + + setCoords({ + x, + y, + }); + }, [referenceElement, positionOffset]); + + return ( + +
{children}
+
+ ); +}; + +DraggableDialog.propTypes = { + referenceElement: PropTypes.node.isRequired, + children: PropTypes.node, + positionOffset: PropTypes.func, +}; + +DraggableDialog.defaultProps = { + children: null, + positionOffset: () => ({ x: 0, y: 0 }), +}; + +export default DraggableDialog; diff --git a/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.group.js b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.group.js new file mode 100644 index 0000000000..c1b0159008 --- /dev/null +++ b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.group.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import PopupMenuItem from './popup.menu.item'; +import { showItem } from './popup.menu.helper'; + +const PopupMenuGroup = ({ items, filterText, onItemClick }) => { + const isAnyItemVisible = items.some((item) => showItem(item, filterText)); + + if (!isAnyItemVisible) { + return null; + } + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +}; + +PopupMenuGroup.propTypes = { + items: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + }), + ), + onItemClick: PropTypes.func.isRequired, + filterText: PropTypes.string, +}; + +PopupMenuGroup.defaultProps = { + items: [], + filterText: '', +}; + +export default PopupMenuGroup; diff --git a/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.helper.js b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.helper.js new file mode 100644 index 0000000000..17aef3e6cd --- /dev/null +++ b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.helper.js @@ -0,0 +1,12 @@ +const MIN_SEARCH_LENGTH = 3; + +export const showItem = (item, filterText) => { + if (filterText.length < MIN_SEARCH_LENGTH) { + return true; + } + + const itemLabelLowerCase = item.label.toLowerCase(); + const filterTextLowerCase = filterText.toLowerCase(); + + return itemLabelLowerCase.indexOf(filterTextLowerCase) === 0; +}; diff --git a/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.item.js b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.item.js new file mode 100644 index 0000000000..ece2c65b34 --- /dev/null +++ b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.item.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { showItem } from './popup.menu.helper'; + +const PopupMenuItem = ({ item, filterText, onItemClick }) => { + if (!showItem(item, filterText)) { + return null; + } + + return ( +
+ +
+ ); +}; + +PopupMenuItem.propTypes = { + item: PropTypes.shape({ + label: PropTypes.string.isRequired, + }).isRequired, + onItemClick: PropTypes.func.isRequired, + filterText: PropTypes.string, +}; + +PopupMenuItem.defaultProps = { + filterText: '', +}; + +export default PopupMenuItem; diff --git a/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.js b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.js new file mode 100644 index 0000000000..103b28a366 --- /dev/null +++ b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.js @@ -0,0 +1,121 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { getRootDOMElement } from '@ibexa-admin-ui/src/bundle/Resources/public/js/scripts/helpers/context.helper'; +import { createCssClassNames } from '@ibexa-admin-ui/src/bundle/ui-dev/src/modules/common/helpers/css.class.names'; + +import PopupMenuSearch from './popup.menu.search'; +import PopupMenuGroup from './popup.menu.group'; + +const MIN_ITEMS_LIST_HEIGHT = 150; + +const PopupMenu = ({ extraClasses, footer, items, onItemClick, positionOffset, referenceElement, scrollContainer, onClose }) => { + const containerRef = useRef(); + const [isRendered, setIsRendered] = useState(false); + const [itemsListStyles, setItemsListStyles] = useState({ + left: 0, + top: 0, + }); + const [filterText, setFilterText] = useState(''); + const numberOfItems = useMemo(() => items.reduce((sum, group) => sum + group.items.length, 0), [items]); + const popupMenuClassName = createCssClassNames({ + 'c-popup-menu': true, + 'c-popup-menu--hidden': !isRendered, + [extraClasses]: true, + }); + const calculateAndSetItemsListStyles = useCallback(() => { + const itemsStyles = {}; + const { top: referenceTop, left: referenceLeft } = referenceElement.getBoundingClientRect(); + const { height: containerHeight } = containerRef.current.getBoundingClientRect(); + const bottom = referenceTop + containerHeight; + + if (window.innerHeight - bottom > MIN_ITEMS_LIST_HEIGHT) { + const { x: offsetX, y: offsetY } = positionOffset(referenceElement, 'bottom'); + + itemsStyles.top = referenceTop + offsetY; + itemsStyles.left = referenceLeft + offsetX; + itemsStyles.maxHeight = window.innerHeight - itemsStyles.top; + } else { + const { x: offsetX, y: offsetY } = positionOffset(referenceElement, 'top'); + + itemsStyles.top = referenceTop + offsetY; + itemsStyles.left = referenceLeft + offsetX; + itemsStyles.maxHeight = itemsStyles.top; + itemsStyles.transform = 'translateY(-100%)'; + } + + setItemsListStyles(itemsStyles); + }, [referenceElement, positionOffset]); + const renderFooter = () => { + if (!footer) { + return null; + } + + return
{footer}
; + }; + + useEffect(() => { + calculateAndSetItemsListStyles(); + setIsRendered(true); + + const rootDOMElement = getRootDOMElement(); + const onInteractionOutside = (event) => { + if (containerRef.current.contains(event.target) || referenceElement.contains(event.target)) { + return; + } + + onClose(); + }; + + rootDOMElement.addEventListener('click', onInteractionOutside, false); + scrollContainer.addEventListener('scroll', calculateAndSetItemsListStyles, false); + + return () => { + rootDOMElement.removeEventListener('click', onInteractionOutside); + scrollContainer.removeEventListener('scroll', calculateAndSetItemsListStyles); + + setItemsListStyles({}); + }; + }, [onClose, scrollContainer, referenceElement, calculateAndSetItemsListStyles]); + + return ( +
+ +
+ {items.map((group) => ( + + ))} +
+ {renderFooter()} +
+ ); +}; + +PopupMenu.propTypes = { + referenceElement: PropTypes.node.isRequired, + extraClasses: PropTypes.string, + footer: PropTypes.node, + items: PropTypes.arrayOf({ + id: PropTypes.string.isRequired, + items: PropTypes.shape({ + id: PropTypes.oneOf([PropTypes.string, PropTypes.number]), + label: PropTypes.string, + }), + }), + onClose: PropTypes.func, + onItemClick: PropTypes.func, + positionOffset: PropTypes.func, + scrollContainer: PropTypes.node, +}; + +PopupMenu.defaultProps = { + extraClasses: '', + footer: null, + items: [], + onClose: () => {}, + onItemClick: () => {}, + positionOffset: () => ({ x: 0, y: 0 }), + scrollContainer: getRootDOMElement(), +}; + +export default PopupMenu; diff --git a/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.search.js b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.search.js new file mode 100644 index 0000000000..ba99b3f5f1 --- /dev/null +++ b/src/bundle/ui-dev/src/modules/common/popup-menu/popup.menu.search.js @@ -0,0 +1,61 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { getTranslator } from '@ibexa-admin-ui/src/bundle/Resources/public/js/scripts/helpers/context.helper'; +import Icon from '@ibexa-admin-ui/src/bundle/ui-dev/src/modules/common/icon/icon'; + +const MIN_SEARCH_ITEMS_DEFAULT = 5; + +const PopupMenuSearch = ({ numberOfItems, filterText, setFilterText }) => { + const Translator = getTranslator(); + const searchPlaceholder = Translator.trans(/*@Desc("Search...")*/ 'ibexa_popup_menu.search.placeholder', {}, 'ibexa_popup_menu'); + const updateFilterValue = (event) => setFilterText(event.target.value); + const resetInputValue = () => setFilterText(''); + + if (numberOfItems < MIN_SEARCH_ITEMS_DEFAULT) { + return null; + } + + return ( +
+
+ +
+ + +
+
+
+ ); +}; + +PopupMenuSearch.propTypes = { + numberOfItems: PropTypes.number.isRequired, + setFilterText: PropTypes.func.isRequired, + filterText: PropTypes.string, +}; + +PopupMenuSearch.defaultProps = { + filterText: '', +}; + +export default PopupMenuSearch;