diff --git a/assets/diagram-js.css b/assets/diagram-js.css
index 1f4a7a305..879fad69e 100644
--- a/assets/diagram-js.css
+++ b/assets/diagram-js.css
@@ -59,6 +59,7 @@
--popup-font-size: 14px;
--popup-header-entry-selected-color: var(--color-blue-205-100-50);
--popup-header-font-weight: bolder;
+ --popup-header-group-divider-color: var(--color-grey-225-10-75);
--popup-background-color: var(--color-white);
--popup-border-color: transparent;
--popup-shadow-color: var(--color-black-opacity-30);
@@ -568,6 +569,29 @@ marker.djs-dragger tspan {
color: inherit;
}
+.djs-popup-header-group {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.djs-popup-header-group .entry {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.djs-popup-header-group + .djs-popup-header-group:before {
+ content: '';
+ width: 1px;
+ height: 20px;
+ background: var(--popup-header-group-divider-color);
+ margin: 0 5px;
+}
+
.djs-popup-search {
margin: 10px 12px;
}
diff --git a/lib/features/popup-menu/PopupMenuComponent.js b/lib/features/popup-menu/PopupMenuComponent.js
index 892b4cfd8..378eeacab 100644
--- a/lib/features/popup-menu/PopupMenuComponent.js
+++ b/lib/features/popup-menu/PopupMenuComponent.js
@@ -13,6 +13,7 @@ import {
matches as domMatches
} from 'min-dom';
+import PopupMenuHeader from './PopupMenuHeader';
import PopupMenuList from './PopupMenuList';
import classNames from 'clsx';
import { isDefined, isFunction } from 'min-dash';
@@ -30,6 +31,7 @@ import { isDefined, isFunction } from 'min-dash';
*
* @param {Object} props
* @param {() => void} props.onClose
+ * @param {() => void} props.onSelect
* @param {(element: HTMLElement) => Point} props.position
* @param {string} props.className
* @param {PopupMenuEntry[]} props.entries
@@ -180,28 +182,13 @@ export default function PopupMenuComponent(props) {
scale=${ scale }
>
${ displayHeader && html`
-
+ <${PopupMenuHeader}
+ headerEntries=${ headerEntries }
+ onSelect=${ onSelect }
+ selectedEntry=${ selectedEntry }
+ setSelectedEntry=${ setSelectedEntry }
+ title=${ title }
+ />
` }
${ originalEntries.length > 0 && html`
@@ -324,14 +311,4 @@ function getPopupStyle(props) {
width: `${props.width}px`,
'transform-origin': 'top left'
};
-}
-
-function getHeaderClasses(entry, selected) {
- return classNames(
- 'entry',
- entry.className,
- entry.active ? 'active' : '',
- entry.disabled ? 'disabled' : '',
- selected ? 'selected' : ''
- );
}
\ No newline at end of file
diff --git a/lib/features/popup-menu/PopupMenuHeader.js b/lib/features/popup-menu/PopupMenuHeader.js
new file mode 100644
index 000000000..4eaa16ab2
--- /dev/null
+++ b/lib/features/popup-menu/PopupMenuHeader.js
@@ -0,0 +1,94 @@
+import classNames from 'clsx';
+
+import {
+ html,
+ useMemo
+} from '../../ui';
+
+/**
+ * @typedef {import('./PopupMenuProvider').PopupMenuHeaderEntry} PopupMenuHeaderEntry
+ */
+
+/**
+ * Component that renders a popup menu header.
+ *
+ * @param {Object} props
+ * @param {PopupMenuHeaderEntry[]} props.headerEntries
+ * @param {PopupMenuHeaderEntry} props.selectedEntry
+ * @param {(event: MouseEvent, entry: PopupMenuHeaderEntry) => void} props.onSelect
+ * @param {(entry: PopupMenuHeaderEntry | null) => void} props.setSelectedEntry
+ * @param {string} props.title
+ */
+export default function PopupMenuHeader(props) {
+ const {
+ headerEntries,
+ onSelect,
+ selectedEntry,
+ setSelectedEntry,
+ title
+ } = props;
+
+ const groups = useMemo(() => groupEntries(headerEntries), [ headerEntries ]);
+
+ return html`
+
+ `;
+}
+
+
+// helpers
+function groupEntries(entries) {
+ return entries.reduce((groups, entry) => {
+ const groupId = entry.group || 'default';
+
+ const group = groups.find(group => group.id === groupId);
+
+ if (group) {
+ group.entries.push(entry);
+ } else {
+ groups.push({
+ id: groupId,
+ entries: [ entry ]
+ });
+ }
+
+ return groups;
+ }, []);
+}
+
+function getHeaderClasses(entry, selected) {
+ return classNames(
+ 'entry',
+ entry.className,
+ entry.active ? 'active' : '',
+ entry.disabled ? 'disabled' : '',
+ selected ? 'selected' : ''
+ );
+}
\ No newline at end of file
diff --git a/lib/features/popup-menu/PopupMenuProvider.spec.ts b/lib/features/popup-menu/PopupMenuProvider.spec.ts
index 0344dfc2d..a8c5e1ad6 100644
--- a/lib/features/popup-menu/PopupMenuProvider.spec.ts
+++ b/lib/features/popup-menu/PopupMenuProvider.spec.ts
@@ -47,7 +47,8 @@ export class BarPopupMenuProvider implements PopupMenuProvider {
className: 'foo',
imageUrl: 'https://example.com/',
imageHtml: '
',
- label: 'Foo'
+ label: 'Foo',
+ group: 'foo'
}
};
};
@@ -73,6 +74,7 @@ export class BarPopupMenuProvider implements PopupMenuProvider {
imageHtml: '
',
label: 'Bar',
title: 'Bar',
+ group: 'bar'
}
];
}
diff --git a/lib/features/popup-menu/PopupMenuProvider.ts b/lib/features/popup-menu/PopupMenuProvider.ts
index 1fc4ef765..0af523850 100644
--- a/lib/features/popup-menu/PopupMenuProvider.ts
+++ b/lib/features/popup-menu/PopupMenuProvider.ts
@@ -7,6 +7,7 @@ export type PopupMenuEntryAction = (event: Event, entry: PopupMenuEntry, action?
export type PopupMenuEntry = {
action: PopupMenuEntryAction;
className: string;
+ group?: string;
imageUrl?: string;
imageHtml?: string;
label: string;
@@ -22,6 +23,7 @@ export type PopupMenuHeaderEntry = {
action: PopupMenuHeaderEntryAction;
active?: boolean;
className: string;
+ group?: string;
id: string;
imageUrl?: string;
imageHtml?: string;
diff --git a/test/spec/features/popup-menu/PopupMenuComponentSpec.js b/test/spec/features/popup-menu/PopupMenuComponentSpec.js
index 9d23fc62e..6ce440fea 100644
--- a/test/spec/features/popup-menu/PopupMenuComponentSpec.js
+++ b/test/spec/features/popup-menu/PopupMenuComponentSpec.js
@@ -364,64 +364,6 @@ describe('features/popup-menu -
', function() {
});
- describe('header', function() {
-
- it('should render header entry', async function() {
-
- // given
- const imageUrl = TEST_IMAGE_URL;
-
- const headerEntries = [
- { id: '1', label: '1' },
- { id: '2', imageUrl, title: 'Toggle foo' }
- ];
-
- await createPopupMenu({ container, headerEntries });
-
- // when
- const [
- firstEntry,
- secondEntry
- ] = domQueryAll('.entry', container);
-
- // then
- expect(firstEntry.title).to.eql('1');
- expect(firstEntry.textContent).to.eql('1');
-
- expect(secondEntry.title).to.eql('Toggle foo');
- expect(secondEntry.textContent).to.eql('');
- expect(secondEntry.innerHTML).to.eql(``);
- });
-
-
- it('should select header entry on hover', async function() {
-
- // given
- const headerEntries = [
- { id: '1', label: '1' },
- { id: '2', label: '2' }
- ];
-
- await createPopupMenu({ container, headerEntries });
-
- const entryEl = domQuery('.entry', container);
-
- // when
- await trigger(entryEl, mouseEnter());
-
- // then
- expect(entryEl.classList.contains('selected'), 'entry is selected').to.be.true;
-
- // but when
- await trigger(entryEl, mouseLeave());
-
- // then
- expect(entryEl.classList.contains('selected')).to.be.false;
- });
-
- });
-
-
it('should render title, if set', async function() {
// given
@@ -843,14 +785,6 @@ function dragStart() {
return new DragEvent('dragstart');
}
-function mouseEnter() {
- return new MouseEvent('mouseenter', { bubbles: true });
-}
-
-function mouseLeave() {
- return new MouseEvent('mouseleave', { bubbles: true });
-}
-
async function trigger(element, event) {
element.dispatchEvent(event);
diff --git a/test/spec/features/popup-menu/PopupMenuHeaderSpec.js b/test/spec/features/popup-menu/PopupMenuHeaderSpec.js
new file mode 100644
index 000000000..47146dd84
--- /dev/null
+++ b/test/spec/features/popup-menu/PopupMenuHeaderSpec.js
@@ -0,0 +1,247 @@
+import PopupMenuHeader from 'lib/features/popup-menu/PopupMenuHeader';
+
+import {
+ html,
+ render
+} from 'lib/ui';
+
+import {
+ query as domQuery,
+ queryAll as domQueryAll
+} from 'min-dom';
+
+const TEST_IMAGE_URL = `data:image/svg+xml;utf8,${
+ encodeURIComponent(`
+
+ `)
+}`;
+
+
+describe('features/popup-menu - ', function() {
+ let container;
+
+ beforeEach(function() {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ afterEach(function() {
+ container.parentNode.removeChild(container);
+ });
+
+
+ it('should render', function() {
+
+ // when
+ createPopupMenuHeader({ container });
+
+ // then
+ expect(domQuery('.djs-popup-header', container)).to.exist;
+ });
+
+
+ it('should render entries', function() {
+
+ // when
+ createPopupMenuHeader({
+ container,
+ headerEntries: [
+ {
+ id: 'foo',
+ label: 'Foo',
+ action: () => {}
+ },
+ {
+ id: 'bar',
+ title: 'Bar',
+ imageUrl: TEST_IMAGE_URL,
+ action: () => {}
+ }
+ ]
+ });
+
+ // then
+ expect(domQuery('.djs-popup-header', container)).to.exist;
+ expect(domQueryAll('.djs-popup-header .entry', container)).to.have.length(2);
+ expect(domQueryAll('.djs-popup-header .divider', container)).to.have.length(0);
+
+ const fooEntry = domQuery('.djs-popup-header .entry[data-id="foo"]', container);
+
+ expect(fooEntry.textContent).to.eql('Foo');
+
+ const barEntry = domQuery('.djs-popup-header .entry[data-id="bar"]', container);
+
+ expect(barEntry.title).to.eql('Bar');
+ expect(barEntry.innerHTML).to.eql(``);
+ });
+
+
+ it('should group entries', function() {
+
+ // when
+ createPopupMenuHeader({
+ container,
+ headerEntries: [
+ {
+ id: 'foo',
+ label: 'Foo',
+ action: () => {}
+ },
+ {
+ id: 'bar',
+ label: 'Bar',
+ action: () => {},
+ group: 'bar'
+ },
+ {
+ id: 'baz',
+ label: 'Baz',
+ action: () => {},
+ group: 'bar'
+ }
+ ]
+ });
+
+ // then
+ expect(domQuery('.djs-popup-header', container)).to.exist;
+ expect(domQueryAll('.djs-popup-header .entry', container)).to.have.length(3);
+ expect(domQueryAll('.djs-popup-header .djs-popup-header-group', container)).to.have.length(2);
+ });
+
+
+ it('should select header entry on mouseenter if has action', async function() {
+
+ // given
+ const spy = sinon.spy();
+
+ createPopupMenuHeader({
+ container,
+ headerEntries: [
+ {
+ id: 'foo',
+ label: 'Foo',
+ action: () => {}
+ },
+ {
+ id: 'bar',
+ label: 'Bar'
+ }
+ ],
+ setSelectedEntry: spy
+ });
+
+ const fooEntry = domQuery('.entry[data-id="foo"]', container);
+
+ // when
+ await trigger(fooEntry, mouseEnter());
+
+ // then
+ expect(spy).to.have.been.calledWithMatch({ id: 'foo' });
+ });
+
+
+ it('should deselect header entry on mouseleave if has action', async function() {
+
+ // given
+ const spy = sinon.spy();
+
+ createPopupMenuHeader({
+ container,
+ selectedEntry: { id: 'foo' },
+ headerEntries: [
+ {
+ id: 'foo',
+ label: 'Foo',
+ action: () => {}
+ },
+ {
+ id: 'bar',
+ label: 'Bar'
+ }
+ ],
+ setSelectedEntry: spy
+ });
+
+ const fooEntry = domQuery('.entry[data-id="foo"]', container);
+
+ // when
+ await trigger(fooEntry, mouseLeave());
+
+ // then
+ expect(spy).to.have.been.calledWithMatch(null);
+ });
+
+
+ it('should not select header entry on mouseenter if not has action', async function() {
+
+ // given
+ const spy = sinon.spy();
+
+ createPopupMenuHeader({
+ container,
+ headerEntries: [
+ {
+ id: 'foo',
+ label: 'Foo',
+ action: () => {}
+ },
+ {
+ id: 'bar',
+ label: 'Bar'
+ }
+ ],
+ setSelectedEntry: spy
+ });
+
+ const barEntry = domQuery('.entry[data-id="bar"]', container);
+
+ // when
+ await trigger(barEntry, mouseEnter());
+
+ // then
+ expect(spy).not.to.have.been.called;
+ });
+
+});
+
+
+// helpers
+function createPopupMenuHeader(options) {
+ const {
+ container,
+ ...rest
+ } = options;
+
+ const props = {
+ headerEntries: [],
+ onSelect: () => {},
+ selectedEntry: null,
+ setSelectedEntry: () => {},
+ ...rest
+ };
+
+ return render(
+ html`<${ PopupMenuHeader } ...${props} />`,
+ container
+ );
+}
+
+function mouseEnter() {
+ return new MouseEvent('mouseenter', { bubbles: true });
+}
+
+function mouseLeave() {
+ return new MouseEvent('mouseleave', { bubbles: true });
+}
+
+async function trigger(element, event) {
+ element.dispatchEvent(event);
+
+ return whenStable(500);
+}
+
+function whenStable(timeout = 50) {
+ return new Promise(resolve => setTimeout(resolve, timeout));
+}
\ No newline at end of file
diff --git a/test/spec/features/popup-menu/PopupMenuListSpec.js b/test/spec/features/popup-menu/PopupMenuListSpec.js
index c20262680..4d6bb32e1 100644
--- a/test/spec/features/popup-menu/PopupMenuListSpec.js
+++ b/test/spec/features/popup-menu/PopupMenuListSpec.js
@@ -30,10 +30,9 @@ describe('features/popup-menu - ', function() {
// then
expect(domQuery('.djs-popup-results', container)).to.exist;
-
});
-});
+});
// helpers