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` -
-

${ title }

- ${ headerEntries.map(entry => html` - <${ entry.action ? 'button' : 'span' } - class=${ getHeaderClasses(entry, entry === selectedEntry) } - onClick=${ event => onSelect(event, entry) } - title=${ entry.title || entry.label } - data-id=${ entry.id } - onMouseEnter=${ () => setSelectedEntry(entry) } - onMouseLeave=${ () => setSelectedEntry(null) } - onFocus=${ () => setSelectedEntry(entry) } - onBlur=${ () => setSelectedEntry(null) } - > - ${(entry.imageUrl && html``) || - (entry.imageHtml && html`
`)} - - ${ entry.label ? html` - ${ entry.label } - ` : null } - - `) } -
+ <${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` +
+

${ title }

+ ${ groups.map((group) => html` +
    + + ${ group.entries.map(entry => html` +
  • + <${ entry.action ? 'button' : 'span' } + class=${ getHeaderClasses(entry, entry === selectedEntry) } + onClick=${ event => entry.action && onSelect(event, entry) } + title=${ entry.title || entry.label } + data-id=${ entry.id } + onMouseEnter=${ () => entry.action && setSelectedEntry(entry) } + onMouseLeave=${ () => entry.action && setSelectedEntry(null) } + onFocus=${ () => entry.action && setSelectedEntry(entry) } + onBlur=${ () => entry.action && setSelectedEntry(null) } + > + ${(entry.imageUrl && html``) || + (entry.imageHtml && html`
    `)} + ${ entry.label ? html` + ${ entry.label } + ` : null } + +
  • + `) } +
+ `) } +
+ `; +} + + +// 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