Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bitter-masks-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sl-design-system/tool-bar': minor
---

Add support for `<sl-menu-button>`
104 changes: 82 additions & 22 deletions packages/components/tool-bar/src/tool-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { faBell, faGear } from '@fortawesome/pro-regular-svg-icons';
import { faBell, faGear, faPen, faTrash } from '@fortawesome/pro-regular-svg-icons';
import { faBell as fasBell, faGear as fasGear } from '@fortawesome/pro-solid-svg-icons';
import '@sl-design-system/button/register.js';
import { Icon } from '@sl-design-system/icon';
import '@sl-design-system/icon/register.js';
import { type MenuItem } from '@sl-design-system/menu';
import '@sl-design-system/menu/register.js';
import '@sl-design-system/toggle-button/register.js';
import '@sl-design-system/toggle-group/register.js';
import { fixture } from '@sl-design-system/vitest-browser-lit';
import { html } from 'lit';
import { spy } from 'sinon';
import { beforeEach, describe, expect, it } from 'vitest';
import '../register.js';
import { type ToolBar, type ToolBarItemButton, type ToolBarItemDivider, type ToolBarItemGroup } from './tool-bar.js';
import {
type ToolBar,
type ToolBarItem,
type ToolBarItemButton,
type ToolBarItemDivider,
type ToolBarItemGroup,
type ToolBarItemMenu
} from './tool-bar.js';

Icon.register(faBell, faGear, fasBell, fasGear);
Icon.register(faBell, faGear, faPen, faTrash, fasBell, fasGear);

describe('sl-tool-bar', () => {
let el: ToolBar;
Expand All @@ -38,6 +47,20 @@ describe('sl-tool-bar', () => {
<sl-icon name="fas-gear" slot="pressed"></sl-icon>
</sl-toggle-button>
</sl-toggle-group>

<sl-tool-bar-divider></sl-tool-bar-divider>

<sl-menu-button>
<div slot="button">Edit</div>
<sl-menu-item>
<sl-icon name="far-pen"></sl-icon>
Rename...
</sl-menu-item>
<sl-menu-item>
<sl-icon name="far-trash"></sl-icon>
Delete...
</sl-menu-item>
</sl-menu-button>
</sl-tool-bar>
`);

Expand Down Expand Up @@ -79,7 +102,7 @@ describe('sl-tool-bar', () => {
it('should have made all slotted elements visible', () => {
const visible = Array.from(el.children).map(child => (child as HTMLElement).style.visibility);

expect(visible).to.deep.equal(['visible', 'visible', 'visible']);
expect(visible).to.deep.equal(['visible', 'visible', 'visible', 'visible', 'visible']);
});

it('should not have a menu button', () => {
Expand All @@ -89,22 +112,31 @@ describe('sl-tool-bar', () => {
});

it('should map the slotted items', () => {
expect(el.items).to.have.length(3);

const button = el.items[0] as ToolBarItemButton;
expect(button.type).to.equal('button');
expect(button.label).to.equal('Button');
expect(button.icon).to.equal('far-gear');
expect(button.visible).to.be.true;

const divider = el.items[1] as ToolBarItemDivider;
expect(divider.type).to.equal('divider');
expect(divider.visible).to.be.true;

const group = el.items[2] as ToolBarItemGroup;
expect(group.type).to.equal('group');
expect(group.selects).to.equal('single');
expect(group.visible).to.be.true;
expect(el.items).to.have.length(5);

let item: ToolBarItem = el.items[0] as ToolBarItemButton;
expect(item.type).to.equal('button');
expect(item.label).to.equal('Button');
expect(item.icon).to.equal('far-gear');
expect(item.visible).to.be.true;

item = el.items[1] as ToolBarItemDivider;
expect(item.type).to.equal('divider');
expect(item.visible).to.be.true;

item = el.items[2] as ToolBarItemGroup;
expect(item.type).to.equal('group');
expect(item.selects).to.equal('single');
expect(item.visible).to.be.true;

item = el.items[3] as ToolBarItemDivider;
expect(item.type).to.equal('divider');
expect(item.visible).to.be.true;

item = el.items[4] as ToolBarItemMenu;
expect(item.type).to.equal('menu');
expect(item.label).to.equal('Edit');
expect(item.visible).to.be.true;
});

it('should update the disabled state of the items when they change', async () => {
Expand Down Expand Up @@ -140,8 +172,23 @@ describe('sl-tool-bar', () => {
<sl-icon name="fas-gear" slot="pressed"></sl-icon>
</sl-toggle-button>
</sl-toggle-group>
<sl-button aria-labelledby="edit-tooltip" fill="ghost"> <sl-icon name="far-pen"></sl-icon></sl-button>

<sl-button aria-labelledby="edit-tooltip" fill="ghost">
<sl-icon name="far-pen"></sl-icon>
</sl-button>
<sl-tooltip id="edit-tooltip">Edit</sl-tooltip>

<sl-menu-button>
<div slot="button">Edit</div>
<sl-menu-item>
<sl-icon name="far-pen"></sl-icon>
Rename...
</sl-menu-item>
<sl-menu-item>
<sl-icon name="far-trash"></sl-icon>
Delete...
</sl-menu-item>
</sl-menu-button>
</sl-tool-bar>
`);

Expand All @@ -152,7 +199,7 @@ describe('sl-tool-bar', () => {
it('should have hidden all slotted elements', () => {
const hidden = Array.from(el.children).map(child => (child as HTMLElement).style.visibility);

expect(hidden).to.deep.equal(['hidden', 'hidden', 'hidden', 'hidden', '']);
expect(hidden).to.deep.equal(['hidden', 'hidden', 'hidden', 'hidden', '', 'hidden']);
});

it('should have a menu button', () => {
Expand Down Expand Up @@ -199,6 +246,19 @@ describe('sl-tool-bar', () => {
expect(lastChild).to.contain('sl-icon[name="far-pen"]');
});

it('should have a menu item with submenu for the menu button', () => {
const menu = el.renderRoot.querySelector('sl-menu')!,
menuItem = menu.parentElement as MenuItem,
menuItems = menu.querySelectorAll('sl-menu-item');

expect(menuItem).to.contain.text('Edit');
expect(menuItems).to.have.length(2);
expect(menuItems[0]).to.have.trimmed.text('Rename...');
expect(menuItems[0]).to.contain('sl-icon[name="far-pen"]');
expect(menuItems[1]).to.have.trimmed.text('Delete...');
expect(menuItems[1]).to.contain('sl-icon[name="far-trash"]');
});

it('should proxy clicks on the menu items to the original elements', () => {
const onClick = spy();

Expand Down
17 changes: 17 additions & 0 deletions packages/components/tool-bar/src/tool-bar.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
faCopy,
faItalic,
faPaste,
faPen,
faScissors,
faTrash,
faUnderline
} from '@fortawesome/pro-regular-svg-icons';
import {
Expand All @@ -24,6 +26,7 @@ import { type Button } from '@sl-design-system/button';
import '@sl-design-system/button/register.js';
import { Icon } from '@sl-design-system/icon';
import '@sl-design-system/icon/register.js';
import '@sl-design-system/menu/register.js';
import '@sl-design-system/toggle-button/register.js';
import '@sl-design-system/toggle-group/register.js';
import { tooltip } from '@sl-design-system/tooltip';
Expand Down Expand Up @@ -51,7 +54,9 @@ Icon.register(
faCopy,
faItalic,
faPaste,
faPen,
faScissors,
faTrash,
faUnderline,
fasAlignCenter,
fasAlignJustify,
Expand Down Expand Up @@ -129,6 +134,18 @@ export const Basic: Story = {
<sl-icon name="far-paste"></sl-icon>
Paste
</sl-button>
<sl-tool-bar-divider></sl-tool-bar-divider>
<sl-menu-button>
<div slot="button">Edit</div>
<sl-menu-item>
<sl-icon name="far-pen"></sl-icon>
Rename...
</sl-menu-item>
<sl-menu-item>
<sl-icon name="far-trash"></sl-icon>
Delete...
</sl-menu-item>
</sl-menu-button>
`
}
};
Expand Down
85 changes: 68 additions & 17 deletions packages/components/tool-bar/src/tool-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { localized, msg } from '@lit/localize';
import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
import { Button, type ButtonFill } from '@sl-design-system/button';
import { Icon } from '@sl-design-system/icon';
import { MenuButton, MenuItem, MenuItemGroup } from '@sl-design-system/menu';
import { Menu, MenuButton, MenuItem, MenuItemGroup } from '@sl-design-system/menu';
import { ToggleButton } from '@sl-design-system/toggle-button';
import { ToggleGroup } from '@sl-design-system/toggle-group';
import { Tooltip } from '@sl-design-system/tooltip';
Expand Down Expand Up @@ -44,7 +44,15 @@ export interface ToolBarItemGroup extends ToolBarItemBase {
selects?: 'single' | 'multiple';
}

export type ToolBarItem = ToolBarItemButton | ToolBarItemDivider | ToolBarItemGroup;
export interface ToolBarItemMenu extends ToolBarItemBase {
type: 'menu';
disabled?: boolean;
icon?: string | null;
label?: string | null;
menuItems: Array<ToolBarItemButton | ToolBarItemDivider | ToolBarItemMenu>;
}

export type ToolBarItem = ToolBarItemButton | ToolBarItemDivider | ToolBarItemGroup | ToolBarItemMenu;

/**
* A responsive container that automatically hides items in an overflow menu when space is limited.
Expand All @@ -59,6 +67,7 @@ export class ToolBar extends ScopedElementsMixin(LitElement) {
static get scopedElements(): ScopedElementsMap {
return {
'sl-icon': Icon,
'sl-menu': Menu,
'sl-menu-button': MenuButton,
'sl-menu-item': MenuItem,
'sl-menu-item-group': MenuItemGroup
Expand Down Expand Up @@ -181,12 +190,19 @@ export class ToolBar extends ScopedElementsMixin(LitElement) {
`;
} else if (item.type === 'divider') {
return html`<hr />`;
} else {
} else if (item.type === 'button') {
return html`
<sl-menu-item @click=${() => item.click?.()} ?disabled=${item.disabled} ?selectable=${item.selectable}>
${item.icon ? html`<sl-icon .name=${item.icon}></sl-icon>` : nothing} ${item.label}
</sl-menu-item>
`;
} else {
return html`
<sl-menu-item ?disabled=${item.disabled}>
${item.icon ? html`<sl-icon .name=${item.icon}></sl-icon>` : nothing} ${item.label}
<sl-menu slot="submenu">${item.menuItems.map(menuItem => this.renderMenuItem(menuItem))}</sl-menu>
</sl-menu-item>
`;
}
}

Expand Down Expand Up @@ -239,12 +255,14 @@ export class ToolBar extends ScopedElementsMixin(LitElement) {
.map(element => {
if (element instanceof Button || element instanceof ToggleButton) {
return this.#mapButtonToItem(element);
} else if (element instanceof MenuButton) {
return this.#mapMenuButtonToItem(element);
} else if (element instanceof ToggleGroup) {
return this.#mapToggleGroupToItem(element);
} else if (element instanceof ToolBarDivider) {
return { element, type: 'divider' };
} else if (!['SL-TOOLTIP'].includes(element.tagName)) {
console.warn(`Unknown element type: ${element.tagName} in sl-tool-bar. Only sl-button elements are allowed.`);
console.warn(`Unknown element type: ${element.tagName} in sl-tool-bar.`);
}

return undefined;
Expand All @@ -256,19 +274,6 @@ export class ToolBar extends ScopedElementsMixin(LitElement) {
this.#resizeObserver.observe(this);
}

#mapToggleGroupToItem(group: ToggleGroup): ToolBarItemGroup {
return {
element: group,
type: 'group',
label: group.getAttribute('aria-label'),
buttons: Array.from(group.children)
.filter(el => !(el instanceof Tooltip))
.map(button => this.#mapButtonToItem(button as HTMLElement)),
selects: group.multiple ? 'multiple' : 'single',
visible: true
};
}

#mapButtonToItem(button: HTMLElement): ToolBarItemButton {
let label: string | undefined = button.getAttribute('aria-label') || button.textContent?.trim();

Expand Down Expand Up @@ -299,4 +304,50 @@ export class ToolBar extends ScopedElementsMixin(LitElement) {
click: () => button.click()
};
}

#mapMenuButtonToItem(menuButton: MenuButton): ToolBarItemMenu {
let label: string | undefined =
menuButton.getAttribute('aria-label') || menuButton.querySelector('[slot="button"]')?.textContent?.trim();

if (menuButton.hasAttribute('aria-labelledby')) {
const buttonLabelledby = menuButton.getAttribute('aria-labelledby');

if (this.querySelector(`#${buttonLabelledby}`)) {
label = this.querySelector(`#${buttonLabelledby}`)?.textContent?.trim();
} else if (
menuButton.nextElementSibling &&
menuButton.nextElementSibling.tagName === 'SL-TOOLTIP' &&
buttonLabelledby === menuButton.nextElementSibling.id
) {
label = menuButton.nextElementSibling.textContent?.trim();
}
} else if (!label && menuButton.hasAttribute('aria-describedby')) {
label = this.querySelector(`#${menuButton.getAttribute('aria-describedby')}`)?.textContent?.trim();
}

const menuItems = Array.from(menuButton.querySelectorAll('sl-menu-item')).map(el => this.#mapButtonToItem(el));

return {
element: menuButton,
type: 'menu',
disabled: menuButton.hasAttribute('disabled') || menuButton.getAttribute('aria-disabled') === 'true',
icon: menuButton.querySelector('sl-icon')?.getAttribute('name'),
label,
menuItems,
visible: true
};
}

#mapToggleGroupToItem(group: ToggleGroup): ToolBarItemGroup {
return {
element: group,
type: 'group',
label: group.getAttribute('aria-label'),
buttons: Array.from(group.children)
.filter(el => !(el instanceof Tooltip))
.map(button => this.#mapButtonToItem(button as HTMLElement)),
selects: group.multiple ? 'multiple' : 'single',
visible: true
};
}
}