Skip to content

Commit

Permalink
feat(popup-menu): allow grouping header entries
Browse files Browse the repository at this point in the history
Closes #896
  • Loading branch information
philippfromme committed May 22, 2024
1 parent 2edac2a commit 1ba9142
Show file tree
Hide file tree
Showing 8 changed files with 380 additions and 101 deletions.
24 changes: 24 additions & 0 deletions assets/diagram-js.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
41 changes: 9 additions & 32 deletions lib/features/popup-menu/PopupMenuComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -180,28 +182,13 @@ export default function PopupMenuComponent(props) {
scale=${ scale }
>
${ displayHeader && html`
<div class="djs-popup-header">
<h3 class="djs-popup-title" title=${ title }>${ title }</h3>
${ 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`<img class="djs-popup-entry-icon" src=${ entry.imageUrl } alt="" />`) ||
(entry.imageHtml && html`<div class="djs-popup-entry-icon" dangerouslySetInnerHTML=${ { __html: entry.imageHtml } } />`)}
${ entry.label ? html`
<span class="djs-popup-label">${ entry.label }</span>
` : null }
</${ entry.action ? 'button' : 'span' }>
`) }
</div>
<${PopupMenuHeader}
headerEntries=${ headerEntries }
onSelect=${ onSelect }
selectedEntry=${ selectedEntry }
setSelectedEntry=${ setSelectedEntry }
title=${ title }
/>
` }
${ originalEntries.length > 0 && html`
<div class="djs-popup-body">
Expand Down Expand Up @@ -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' : ''
);
}
94 changes: 94 additions & 0 deletions lib/features/popup-menu/PopupMenuHeader.js
Original file line number Diff line number Diff line change
@@ -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`
<div class="djs-popup-header">
<h3 class="djs-popup-title" title=${ title }>${ title }</h3>
${ groups.map((group) => html`
<ul key=${ group.id } class="djs-popup-header-group" data-header-group=${ group.id }>
${ group.entries.map(entry => html`
<li key=${ entry.id }>
<${ 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`<img class="djs-popup-entry-icon" src=${ entry.imageUrl } alt="" />`) ||
(entry.imageHtml && html`<div class="djs-popup-entry-icon" dangerouslySetInnerHTML=${ { __html: entry.imageHtml } } />`)}
${ entry.label ? html`
<span class="djs-popup-label">${ entry.label }</span>
` : null }
</${ entry.action ? 'button' : 'span' }>
</li>
`) }
</ul>
`) }
</div>
`;
}


// 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' : ''
);
}
4 changes: 3 additions & 1 deletion lib/features/popup-menu/PopupMenuProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export class BarPopupMenuProvider implements PopupMenuProvider {
className: 'foo',
imageUrl: 'https://example.com/',
imageHtml: '<img src="https://example.com/" />',
label: 'Foo'
label: 'Foo',
group: 'foo'
}
};
};
Expand All @@ -73,6 +74,7 @@ export class BarPopupMenuProvider implements PopupMenuProvider {
imageHtml: '<img src="https://example.com/" />',
label: 'Bar',
title: 'Bar',
group: 'bar'
}
];
}
Expand Down
2 changes: 2 additions & 0 deletions lib/features/popup-menu/PopupMenuProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +23,7 @@ export type PopupMenuHeaderEntry = {
action: PopupMenuHeaderEntryAction;
active?: boolean;
className: string;
group?: string;
id: string;
imageUrl?: string;
imageHtml?: string;
Expand Down
66 changes: 0 additions & 66 deletions test/spec/features/popup-menu/PopupMenuComponentSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -364,64 +364,6 @@ describe('features/popup-menu - <PopupMenu>', 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(`<img class="djs-popup-entry-icon" src="${ imageUrl }" alt="">`);
});


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
Expand Down Expand Up @@ -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);

Expand Down
Loading

0 comments on commit 1ba9142

Please sign in to comment.