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...
+ 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;