remove old dropdown, menu, menu item, menu label

This commit is contained in:
Cory LaViska
2025-06-06 10:16:14 -04:00
parent 9c9d1900dd
commit c1441abe15
18 changed files with 6 additions and 1892 deletions

View File

@@ -1,125 +0,0 @@
---
title: Menu Item
description: Menu items provide options for the user to pick from in a menu.
tags: component
parent: menu
icon: menu
---
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item>Option 1</wa-menu-item>
<wa-menu-item>Option 2</wa-menu-item>
<wa-menu-item>Option 3</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item type="checkbox" checked>Checkbox</wa-menu-item>
<wa-menu-item disabled>Disabled</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>
Prefix Icon
<wa-icon slot="prefix" name="gift" variant="solid"></wa-icon>
</wa-menu-item>
<wa-menu-item>
Suffix Icon
<wa-icon slot="suffix" name="heart" variant="solid"></wa-icon>
</wa-menu-item>
</wa-menu>
```
## Examples
### Prefix & Suffix
Add content to the start and end of menu items using the `prefix` and `suffix` slots.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item>
<wa-icon slot="prefix" name="house" variant="solid"></wa-icon>
Home
</wa-menu-item>
<wa-menu-item>
<wa-icon slot="prefix" name="envelope" variant="solid"></wa-icon>
Messages
<wa-badge slot="suffix" variant="brand" pill>12</wa-badge>
</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>
<wa-icon slot="prefix" name="gear" variant="solid"></wa-icon>
Settings
</wa-menu-item>
</wa-menu>
```
### Disabled
Add the `disabled` attribute to disable the menu item so it cannot be selected.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item>Option 1</wa-menu-item>
<wa-menu-item disabled>Option 2</wa-menu-item>
<wa-menu-item>Option 3</wa-menu-item>
</wa-menu>
```
### Loading
Use the `loading` attribute to indicate that a menu item is busy. Like a disabled menu item, clicks will be suppressed until the loading state is removed.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item>Option 1</wa-menu-item>
<wa-menu-item loading>Option 2</wa-menu-item>
<wa-menu-item>Option 3</wa-menu-item>
</wa-menu>
```
### Checkbox Menu Items
Set the `type` attribute to `checkbox` to create a menu item that will toggle on and off when selected. You can use the `checked` attribute to set the initial state.
Checkbox menu items are visually indistinguishable from regular menu items. Their ability to be toggled is primarily inferred from context, much like you'd find in the menu of a native app.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item type="checkbox">Autosave</wa-menu-item>
<wa-menu-item type="checkbox" checked>Check Spelling</wa-menu-item>
<wa-menu-item type="checkbox">Word Wrap</wa-menu-item>
</wa-menu>
```
### Value & Selection
The `value` attribute can be used to assign a hidden value, such as a unique identifier, to a menu item. When an item is selected, the `wa-select` event will be emitted and a reference to the item will be available at `event.detail.item`. You can use this reference to access the selected item's value, its checked state, and more.
```html {.example}
<wa-menu class="menu-value" style="max-width: 200px;">
<wa-menu-item value="opt-1">Option 1</wa-menu-item>
<wa-menu-item value="opt-2">Option 2</wa-menu-item>
<wa-menu-item value="opt-3">Option 3</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item type="checkbox" value="opt-4">Checkbox 4</wa-menu-item>
<wa-menu-item type="checkbox" value="opt-5">Checkbox 5</wa-menu-item>
<wa-menu-item type="checkbox" value="opt-6">Checkbox 6</wa-menu-item>
</wa-menu>
<script>
const menu = document.querySelector('.menu-value');
menu.addEventListener('wa-select', event => {
const item = event.detail.item;
// Log value
if (item.type === 'checkbox') {
console.log(`Selected value: ${item.value} (${item.checked ? 'checked' : 'unchecked'})`);
} else {
console.log(`Selected value: ${item.value}`);
}
});
</script>
```

View File

@@ -1,21 +0,0 @@
---
title: Menu Label
description: Menu labels are used to describe a group of menu items.
tags: component
parent: menu
icon: menu
---
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-label>Fruits</wa-menu-label>
<wa-menu-item value="apple">Apple</wa-menu-item>
<wa-menu-item value="banana">Banana</wa-menu-item>
<wa-menu-item value="orange">Orange</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-label>Vegetables</wa-menu-label>
<wa-menu-item value="broccoli">Broccoli</wa-menu-item>
<wa-menu-item value="carrot">Carrot</wa-menu-item>
<wa-menu-item value="zucchini">Zucchini</wa-menu-item>
</wa-menu>
```

View File

@@ -1,77 +0,0 @@
---
title: Menu
description: Menus provide a list of options for the user to choose from.
tags: [actions, apps]
icon: menu
---
You can use [menu items](/docs/components/menu-item), [menu labels](/docs/components/menu-label), and [dividers](/docs/components/divider) to compose a menu. Menus support keyboard interactions, including type-to-select an option.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item value="undo">Undo</wa-menu-item>
<wa-menu-item value="redo">Redo</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item value="cut">Cut</wa-menu-item>
<wa-menu-item value="copy">Copy</wa-menu-item>
<wa-menu-item value="paste">Paste</wa-menu-item>
<wa-menu-item value="delete">Delete</wa-menu-item>
</wa-menu>
```
:::info
Menus are intended for system menus (dropdown menus, select menus, context menus, etc.). They should not be mistaken for navigation menus which serve a different purpose and have a different semantic meaning. If you're building navigation, use `<nav>` and `<a>` elements instead.
:::
## Examples
### In Dropdowns
Menus work really well when used inside [dropdowns](/docs/components/dropdown).
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>Edit</wa-button>
<wa-menu>
<wa-menu-item value="cut">Cut</wa-menu-item>
<wa-menu-item value="copy">Copy</wa-menu-item>
<wa-menu-item value="paste">Paste</wa-menu-item>
</wa-menu>
</wa-dropdown>
```
### Submenus
To create a submenu, nest an `<wa-menu slot="submenu">` in any [menu item](/docs/components/menu-item).
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item value="undo">Undo</wa-menu-item>
<wa-menu-item value="redo">Redo</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item value="cut">Cut</wa-menu-item>
<wa-menu-item value="copy">Copy</wa-menu-item>
<wa-menu-item value="paste">Paste</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>
Find
<wa-menu slot="submenu">
<wa-menu-item value="find">Find…</wa-menu-item>
<wa-menu-item value="find-previous">Find Next</wa-menu-item>
<wa-menu-item value="find-next">Find Previous</wa-menu-item>
</wa-menu>
</wa-menu-item>
<wa-menu-item>
Transformations
<wa-menu slot="submenu">
<wa-menu-item value="uppercase">Make uppercase</wa-menu-item>
<wa-menu-item value="lowercase">Make lowercase</wa-menu-item>
<wa-menu-item value="capitalize">Capitalize</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
```
:::warning
As a UX best practice, avoid using more than one level of submenus when possible.
:::

View File

@@ -31,7 +31,7 @@ During the alpha period, things might break! We take breaking changes very serio
- `<wa-tab-group no-scroll-controls>` => `<wa-tab-group without-scroll-controls>`
- `<wa-tag removable>` => `<wa-tag with-remove>`
- 🚨 BREAKING: removed the `size` attribute from `<wa-card>`; please set the size of child elements on the children directly
- 🚨 BREAKING: Greatly simplified the sizing strategy across components and utilities
- 🚨 BREAKING: greatly simplified the sizing strategy across components and utilities
- Removed `--wa-size`, `--wa-size-smaller`, `--wa-size-larger`, `--wa-space`, `--wa-space-smaller`, and `--wa-space-larger`
- Added tokens for `--wa-form-control-padding-inline`, `--wa-form-control-padding-block`, and `--wa-form-control-toggle-size`
- Refactored default `--wa-font-size-*` values to use an apparent 1.125 ratio and round rendered values to the nearest whole pixel
@@ -39,6 +39,9 @@ During the alpha period, things might break! We take breaking changes very serio
- Updated components to use relative `em` values for internal padding and margin wherever appropriate
- 🚨 BREAKING: removed the `hint` property and slot from `<wa-radio>`; please apply hints directly to `<wa-radio-group>` instead
- 🚨 BREAKING: removed `<wa-icon-button>`; use `<wa-button><wa-icon name="..." label="..."></wa-icon></wa-button>` instead
- 🚨 BREAKING: completely reworked `<wa-dropdown>` to be easier to use
- Added `<wa-dropdown-item>`, greatly simplifying the dropdown's markup structure
- Removed `<wa-menu>`, `<wa-menu-item>`, and `<wa-menu-label>`; use `<wa-dropdown-item>` and native headings instead
- Added a new free component: `<wa-popover>` (#2 of 14 per stretch goals)
- Added a new free component: `<wa-zoomable-frame>` (#3 of 14 per stretch goals)
- Added a `min-block-size` to `<wa-divider orientation="vertical">` to ensure the divider is visible regardless of container height [issue:675]

View File

@@ -1,405 +0,0 @@
import { expect, waitUntil } from '@open-wc/testing';
import { sendKeys, sendMouse } from '@web/test-runner-commands';
import { html } from 'lit';
import sinon from 'sinon';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import type WaDropdown from './dropdown.js';
describe('<wa-dropdown>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
expect(panel.hidden).to.be.false;
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
expect(panel.hidden).to.be.true;
});
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
// @TODO: Fix this [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
// @TODO: Fix this [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
it('should still open on arrow navigation when no menu items', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu> </wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.true;
});
it('should open on arrow down navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const firstMenuItem = el.querySelectorAll('wa-menu-item')[0];
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(firstMenuItem);
});
it('should open on arrow up navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const secondMenuItem = el.querySelectorAll('wa-menu-item')[1];
trigger.focus();
await sendKeys({ press: 'ArrowUp' });
await el.updateComplete;
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(secondMenuItem);
});
it('should navigate to first focusable item on arrow navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-label>Top Label</wa-menu-label>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const item = el.querySelector('wa-menu-item')!;
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(document.activeElement).to.equal(item);
});
it('should close on escape key', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await sendKeys({ press: 'Escape' });
await el.updateComplete;
expect(el.open).to.be.false;
});
it('should not open on arrow navigation when no menu exists', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<div>Some custom content</div>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.false;
});
it('should open on enter key', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await el.updateComplete;
await sendKeys({ press: 'Enter' });
await el.updateComplete;
expect(el.open).to.be.true;
});
it('should focus on menu items when clicking the trigger and arrowing through options', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const secondMenuItem = el.querySelectorAll('wa-menu-item')[1];
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(document.activeElement).to.equal(secondMenuItem);
});
it('should open on enter key when no menu exists', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<div>Some custom content</div>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await el.updateComplete;
await sendKeys({ press: 'Enter' });
await el.updateComplete;
expect(el.open).to.be.true;
});
it('should hide when clicked outside container and initially open', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
await sendMouse({ type: 'click', position: [0, 0] });
await el.updateComplete;
expect(el.open).to.be.false;
});
it('should hide when clicked outside container', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.click();
await el.updateComplete;
await sendMouse({ type: 'click', position: [0, 0] });
await el.updateComplete;
expect(el.open).to.be.false;
});
it('should close and stop propagating the keydown event when Escape is pressed and the dropdown is open ', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Dropdown Item 1</wa-menu-item>
<wa-menu-item>Dropdown Item 2</wa-menu-item>
<wa-menu-item>Dropdown Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const firstMenuItem = el.querySelector('wa-menu-item')!;
const hideHandler = sinon.spy();
document.body.addEventListener('keydown', hideHandler);
firstMenuItem.focus();
await sendKeys({ press: 'Escape' });
await el.updateComplete;
expect(el.open).to.be.false;
if ('CloseWatcher' in window) {
return;
}
// @TODO: Fix this [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
expect(hideHandler).to.not.have.been.called;
});
});
}
});

View File

@@ -5,7 +5,6 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { WaAfterHideEvent } from '../../events/after-hide.js';
import { WaAfterShowEvent } from '../../events/after-show.js';
import { WaHideEvent } from '../../events/hide.js';
import type { WaSelectEvent } from '../../events/select.js';
import { WaShowEvent } from '../../events/show.js';
import { animateWithClass } from '../../internal/animate.js';
import { waitForEvent } from '../../internal/event.js';
@@ -13,7 +12,6 @@ import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import sizeStyles from '../../styles/utilities/size.css';
import type WaButton from '../button/button.js';
import type WaMenu from '../menu/menu.js';
import '../popup/popup.js';
import type WaPopup from '../popup/popup.js';
import styles from './dropdown.css';
@@ -132,7 +130,7 @@ export default class WaDropdown extends WebAwesomeElement {
getMenu() {
return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'wa-menu') as
| WaMenu
| any
| undefined;
}
@@ -193,7 +191,7 @@ export default class WaDropdown extends WebAwesomeElement {
}
};
private handlePanelSelect = (event: WaSelectEvent) => {
private handlePanelSelect = (event: CustomEvent) => {
const target = event.target as HTMLElement;
// Hide the dropdown when a menu item is selected

View File

@@ -1,149 +0,0 @@
:host {
--background-color-hover: var(--wa-color-neutral-fill-normal);
--text-color-hover: var(--wa-color-neutral-on-normal);
--submenu-offset: -0.125rem;
display: block;
color: var(--wa-color-text-normal);
position: relative;
display: flex;
align-items: stretch;
font: inherit;
padding: 0.5em 0.25em;
line-height: var(--wa-line-height-condensed);
transition: fill var(--wa-transition-normal) var(--wa-transition-easing);
user-select: none;
-webkit-user-select: none;
white-space: nowrap;
cursor: pointer;
}
:host([inert]) {
display: none;
}
:host([disabled]) {
outline: none;
opacity: 0.5;
cursor: not-allowed;
}
:host([loading]) {
outline: none;
cursor: wait;
}
:host([loading]) *:not(wa-spinner) {
opacity: 0.5;
}
:host([loading]) wa-spinner {
--indicator-color: currentColor;
--track-width: round(0.0625em, 1px);
position: absolute;
font-size: var(--wa-font-size-smaller);
top: calc(50% - 0.5em);
left: 0.6em;
opacity: 1;
}
.label {
flex: 1 1 auto;
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
}
.prefix {
flex: 0 0 auto;
display: flex;
align-items: center;
}
.prefix::slotted(*) {
margin-inline-end: 0.5em;
}
.suffix {
flex: 0 0 auto;
display: flex;
align-items: center;
}
.suffix::slotted(*) {
margin-inline-start: 0.5em;
}
/* Safe triangle */
:host(:state(submenu-expanded))::after {
content: '';
position: fixed;
z-index: 899;
top: 0;
right: 0;
bottom: 0;
left: 0;
clip-path: polygon(
var(--safe-triangle-cursor-x, 0) var(--safe-triangle-cursor-y, 0),
var(--safe-triangle-submenu-start-x, 0) var(--safe-triangle-submenu-start-y, 0),
var(--safe-triangle-submenu-end-x, 0) var(--safe-triangle-submenu-end-y, 0)
);
}
:host(:focus-visible) {
outline: none;
}
:host(:hover:not([aria-disabled='true'], :focus-visible)),
:host(:state(submenu-expanded)) {
background-color: var(--background-color-hover);
color: var(--text-color-hover);
}
:host(:focus-visible) {
outline: var(--wa-focus-ring);
outline-offset: calc(-1 * var(--wa-focus-ring-width));
background: var(--background-color-hover);
color: var(--text-color-hover);
opacity: 1;
}
.check,
.chevron {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--wa-font-size-smaller);
width: 2em;
visibility: hidden;
}
:host([checked]) .check,
:host(:state(has-submenu)) .chevron {
visibility: visible;
}
/* Add elevation and z-index to submenus */
wa-popup::part(popup) {
box-shadow: var(--wa-shadow-m);
z-index: 900;
margin-left: var(--submenu-offset);
}
wa-popup:dir(rtl)::part(popup) {
margin-left: calc(-1 * var(--submenu-offset));
}
@media (forced-colors: active) {
:host(:hover:not([aria-disabled='true'])),
:host(:focus-visible) {
outline: dashed 1px SelectedItem;
outline-offset: -1px;
}
}
::slotted(wa-menu) {
max-width: var(--auto-size-available-width) !important;
max-height: var(--auto-size-available-height) !important;
}

View File

@@ -1,201 +0,0 @@
import { aTimeout, expect, waitUntil } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import { html } from 'lit';
import sinon from 'sinon';
import type { WaSelectEvent } from '../../events/select.js';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import type WaMenuItem from './menu-item.js';
describe('<wa-menu-item>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item type="checkbox" checked>Checked</wa-menu-item>
<wa-menu-item type="checkbox">Unchecked</wa-menu-item>
</wa-menu>
`);
await expect(el).to.be.accessible();
});
it('should pass accessibility tests when using a submenu', async () => {
const el = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>
Submenu
<wa-menu slot="submenu">
<wa-menu-item>Submenu Item 1</wa-menu-item>
<wa-menu-item>Submenu Item 2</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
await expect(el).to.be.accessible();
});
it('should have the correct default properties', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Test</wa-menu-item> `);
expect(el.value).to.equal('');
expect(el.disabled).to.be.false;
expect(el.loading).to.equal(false);
expect(el.getAttribute('aria-disabled')).to.equal('false');
});
it('should render the correct aria attributes when disabled', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item disabled>Test</wa-menu-item> `);
expect(el.getAttribute('aria-disabled')).to.equal('true');
});
describe('when loading', () => {
it('should have a spinner present', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item loading>Menu Item Label</wa-menu-item> `);
expect(el.shadowRoot!.querySelector('wa-spinner')).to.exist;
});
});
it('defaultLabel should return a text label', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Test</wa-menu-item> `);
expect(el.defaultLabel).to.equal('Test');
expect(el.label).to.equal('Test');
});
it('label attribute should override default label', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item label="Manual label">Text content</wa-menu-item> `);
expect(el.defaultLabel).to.equal('Text content');
expect(el.label).to.equal('Manual label');
});
it('should emit the slotchange event when the label changes', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Text</wa-menu-item> `);
const slotChangeHandler = sinon.spy();
el.addEventListener('slotchange', slotChangeHandler);
el.textContent = 'New Text';
await waitUntil(() => slotChangeHandler.calledOnce);
expect(slotChangeHandler).to.have.been.calledOnce;
});
it('should render a hidden menu item when the inert attribute is used', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item inert>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
`);
const item1 = menu.querySelector('wa-menu-item')!;
expect(getComputedStyle(item1).display).to.equal('none');
});
it('should not render a wa-popup if the slot="submenu" attribute is missing, but the slot should exist in the component and be hidden.', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>
Item 1
<wa-menu>
<wa-menu-item> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const menuItem: HTMLElement = menu.querySelector('wa-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('wa-popup')).to.be.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.true;
});
it('should render a wa-popup if the slot="submenu" attribute is present', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="test">
Item 1
<wa-menu slot="submenu">
<wa-menu-item> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const menuItem = menu.querySelector('wa-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('wa-popup')).to.be.not.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.false;
});
it('should focus on first menuitem of submenu if ArrowRight is pressed on parent menuitem', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="item-1">
Submenu
<wa-menu slot="submenu">
<wa-menu-item value="submenu-item-1"> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
expect(item.value).to.equal('submenu-item-1');
});
menu.addEventListener('wa-select', selectHandler);
const submenu = menu.querySelector<WaMenuItem>('wa-menu-item')!;
// Sometimes Chrome fails if we dont click before triggering focus.
await clickOnElement(submenu);
submenu.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
await menu.updateComplete;
await sendKeys({ press: 'Enter' });
await menu.updateComplete;
// Once for each menu element.
expect(selectHandler).to.have.been.calledTwice;
});
it('should focus on outer menu if ArrowRight is pressed on nested menuitem', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="outer" value="outer-item-1">
Submenu
<wa-menu slot="submenu">
<wa-menu-item value="inner-item-1"> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const focusHandler = sinon.spy((event: FocusEvent) => {
const target = event.target as WaMenuItem;
const relatedTarget = event.relatedTarget as WaMenuItem;
expect(target.value).to.equal('outer-item-1');
expect(relatedTarget.value).to.equal('inner-item-1');
});
const outerItem = menu.querySelector<WaMenuItem>('#outer')!;
// Silly fix for CI + Chrome to focus properly.
await clickOnElement(outerItem);
outerItem.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
outerItem.addEventListener('focus', focusHandler);
await menu.updateComplete;
await sendKeys({ press: 'ArrowLeft' });
await menu.updateComplete;
expect(focusHandler).to.have.been.calledOnce;
});
});
}
});

View File

@@ -1,238 +0,0 @@
import type { PropertyValues } from 'lit';
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import getText from '../../internal/get-text.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import { LocalizeController } from '../../utilities/localize.js';
import '../icon/icon.js';
import '../popup/popup.js';
import '../spinner/spinner.js';
import styles from './menu-item.css';
import { SubmenuController } from './submenu-controller.js';
/**
* @summary Menu items provide options for the user to pick from in a menu.
* @documentation https://backers.webawesome.com/docs/components/menu-item
* @status stable
* @since 2.0
*
* @dependency wa-icon
* @dependency wa-popup
*
* @slot - The menu item's label.
* @slot prefix - Used to prepend an icon or similar element to the menu item.
* @slot suffix - Used to append an icon or similar element to the menu item.
* @slot submenu - Used to denote a nested menu.
* @slot checked-icon - The icon used to indicate that this menu item is checked. Usually a `<wa-icon>`.
* @slot submenu-icon - The icon used to indicate that this menu item has a submenu. Usually a `<wa-icon>`.
*
* @csspart checked-icon - The checked icon, which is only visible when the menu item is checked.
* @csspart prefix - The prefix container.
* @csspart label - The menu item label.
* @csspart suffix - The suffix container.
* @csspart spinner - The spinner that shows when the menu item is in the loading state.
* @csspart spinner__base - The spinner's base part.
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
*
* @cssproperty --background-color-hover - The menu item's background color on hover.
* @cssproperty --text-color-hover - The label color on hover.
* @cssproperty [--submenu-offset=-2px] - The distance submenus shift to overlap the parent menu.
*
* @cssstate has-submenu - Applied when the menu item has a submenu.
* @cssstate submenu-expanded - Applied when the menu item has a submenu and it is expanded.
*/
@customElement('wa-menu-item')
export default class WaMenuItem extends WebAwesomeElement {
static css = styles;
private readonly localize = new LocalizeController(this);
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.menu-item') menuItem: HTMLElement;
/** The type of menu item to render. To use `checked`, this value must be set to `checkbox`. */
@property() type: 'normal' | 'checkbox' = 'normal';
/** Draws the item in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */
@property() value = '';
/** Draws the menu item in a loading state. */
@property({ type: Boolean, reflect: true }) loading = false;
/** Draws the menu item in a disabled state, preventing selection. */
@property({ type: Boolean, reflect: true }) disabled = false;
_label: string = '';
/**
* The options plain text label.
* Usually automatically generated, but can be useful to provide manually for cases involving complex content.
*/
@property()
set label(value) {
const oldValue = this._label;
this._label = value || '';
if (this._label !== oldValue) {
this.requestUpdate('label', oldValue);
}
}
get label(): string {
if (this._label) {
return this._label;
}
if (!this.defaultLabel) {
this.updateDefaultLabel();
}
return this.defaultLabel;
}
/** The default label, generated from the element contents. Will be equal to `label` in most cases. */
@state() defaultLabel = '';
/**
* Used for SSR purposes. If true, will render a ">" caret icon for showing that it has a submenu, but will be non-interactive.
*/
@property({ attribute: 'with-submenu', type: Boolean }) withSubmenu = false;
private submenuController: SubmenuController = new SubmenuController(this);
connectedCallback() {
super.connectedCallback();
this.addEventListener('click', this.handleHostClick);
this.addEventListener('mouseover', this.handleMouseOver);
this.updateDefaultLabel();
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this.handleHostClick);
this.removeEventListener('mouseover', this.handleMouseOver);
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
// Kick it so that it renders the "submenu" properly.
if (this.isSubmenu()) {
this.requestUpdate();
}
super.firstUpdated(changedProperties);
}
private handleDefaultSlotChange() {
let labelChanged = this.updateDefaultLabel();
// When the label changes, emit a slotchange event so parent controls see it
if (labelChanged) {
/** @internal - prevent the CEM from recording this event */
this.dispatchEvent(new Event('slotchange', { bubbles: true, composed: false, cancelable: false }));
}
this.customStates.set('has-submenu', this.isSubmenu());
}
private handleHostClick = (event: MouseEvent) => {
// Prevent the click event from being emitted when the button is disabled or loading
if (this.disabled) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
private handleMouseOver = (event: MouseEvent) => {
this.focus();
event.stopPropagation();
};
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has('checked')) {
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
if (this.checked && this.type !== 'checkbox') {
this.checked = false;
return;
}
// Only checkbox types can receive the aria-checked attribute
if (this.type === 'checkbox') {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
} else {
this.removeAttribute('aria-checked');
}
}
if (changedProperties.has('disabled')) {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
if (changedProperties.has('type')) {
if (this.type === 'checkbox') {
this.setAttribute('role', 'menuitemcheckbox');
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
} else {
this.setAttribute('role', 'menuitem');
this.removeAttribute('aria-checked');
}
}
}
private updateDefaultLabel() {
let oldValue = this.defaultLabel;
this.defaultLabel = getText(this).trim();
let changed = this.defaultLabel !== oldValue;
if (!this._label && changed) {
// Uses default label, and it has changed
this.requestUpdate('label', oldValue);
}
return changed;
}
/** Does this element have a submenu? */
private isSubmenu() {
return this.hasUpdated ? this.querySelector(`:scope > [slot="submenu"]`) !== null : this.withSubmenu;
}
render() {
const isRtl = this.hasUpdated ? this.localize.dir() === 'rtl' : this.dir === 'rtl';
const isSubmenuExpanded = this.submenuController.isExpanded();
this.customStates.set('submenu-expanded', isSubmenuExpanded);
this.internals.ariaHasPopup = this.isSubmenu() + '';
this.internals.ariaExpanded = isSubmenuExpanded + '';
return html`
<slot name="checked-icon" part="checked-icon" class="check">
<wa-icon name="check" library="system" variant="solid" aria-hidden="true"></wa-icon>
</slot>
<slot name="prefix" part="prefix" class="prefix"></slot>
<slot part="label" class="label" @slotchange=${this.handleDefaultSlotChange}></slot>
<slot name="suffix" part="suffix" class="suffix"></slot>
<slot name="submenu-icon" part="submenu-icon" class="chevron">
<wa-icon
name=${isRtl ? 'chevron-left' : 'chevron-right'}
library="system"
variant="solid"
aria-hidden="true"
></wa-icon>
</slot>
${this.submenuController.renderSubmenu()} ${this.loading ? html`<wa-spinner part="spinner"></wa-spinner>` : ''}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-menu-item': WaMenuItem;
}
}

View File

@@ -1,285 +0,0 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { html } from 'lit';
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import type WaPopup from '../popup/popup.js';
import type WaMenuItem from './menu-item.js';
/** A reactive controller to manage the registration of event listeners for submenus. */
export class SubmenuController implements ReactiveController {
private host: ReactiveControllerHost & WaMenuItem;
private popupRef: Ref<WaPopup> = createRef();
private enableSubmenuTimer = -1;
private isConnected = false;
private isPopupConnected = false;
private skidding = 0;
private readonly submenuOpenDelay = 100;
constructor(host: ReactiveControllerHost & WaMenuItem) {
(this.host = host).addController(this);
}
private hasSubmenu() {
return this.host.querySelector(`:scope > [slot="submenu"]`) !== null;
}
hostConnected() {
if (this.hasSubmenu() && !this.host.disabled) {
this.addListeners();
}
}
hostDisconnected() {
this.removeListeners();
}
hostUpdated() {
if (this.hasSubmenu() && !this.host.disabled) {
this.addListeners();
this.updateSkidding();
} else {
this.removeListeners();
}
}
private addListeners() {
if (!this.isConnected) {
this.host.addEventListener('mousemove', this.handleMouseMove);
this.host.addEventListener('mouseover', this.handleMouseOver);
this.host.addEventListener('keydown', this.handleKeyDown);
this.host.addEventListener('click', this.handleClick);
this.host.addEventListener('focusout', this.handleFocusOut);
this.isConnected = true;
}
// The popup does not seem to get wired when the host is
// connected, so manage its listeners separately.
if (!this.isPopupConnected) {
if (this.popupRef.value) {
this.popupRef.value.addEventListener('mouseover', this.handlePopupMouseover);
this.popupRef.value.addEventListener('wa-reposition', this.handlePopupReposition);
this.isPopupConnected = true;
}
}
}
private removeListeners() {
if (this.isConnected) {
this.host.removeEventListener('mousemove', this.handleMouseMove);
this.host.removeEventListener('mouseover', this.handleMouseOver);
this.host.removeEventListener('keydown', this.handleKeyDown);
this.host.removeEventListener('click', this.handleClick);
this.host.removeEventListener('focusout', this.handleFocusOut);
this.isConnected = false;
}
if (this.isPopupConnected) {
if (this.popupRef.value) {
this.popupRef.value.removeEventListener('mouseover', this.handlePopupMouseover);
this.popupRef.value.removeEventListener('wa-reposition', this.handlePopupReposition);
this.isPopupConnected = false;
}
}
}
// Set the safe triangle cursor position
private handleMouseMove = (event: MouseEvent) => {
this.host.style.setProperty('--safe-triangle-cursor-x', `${event.clientX}px`);
this.host.style.setProperty('--safe-triangle-cursor-y', `${event.clientY}px`);
};
private handleMouseOver = () => {
if (this.hasSubmenu()) {
this.enableSubmenu();
}
};
private handleSubmenuEntry(event: KeyboardEvent) {
// Pass focus to the first menu-item in the submenu.
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
// Missing slot
if (!submenuSlot) {
return;
}
// Menus
let menuItems: NodeListOf<Element> | null = null;
for (const elt of submenuSlot.assignedElements()) {
menuItems = elt.querySelectorAll("wa-menu-item, [role^='menuitem']");
if (menuItems.length !== 0) {
break;
}
}
if (!menuItems || menuItems.length === 0) {
return;
}
menuItems[0].setAttribute('tabindex', '0');
for (let i = 1; i !== menuItems.length; ++i) {
menuItems[i].setAttribute('tabindex', '-1');
}
// Open the submenu (if not open), and set focus to first menuitem.
if (this.popupRef.value) {
event.preventDefault();
event.stopPropagation();
if (this.popupRef.value.active) {
if (menuItems[0] instanceof HTMLElement) {
menuItems[0].focus();
}
} else {
this.enableSubmenu(false);
this.host.updateComplete.then(() => {
if (menuItems[0] instanceof HTMLElement) {
menuItems[0].focus();
}
});
this.host.requestUpdate();
}
}
}
// Focus on the first menu-item of a submenu.
private handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Escape':
case 'Tab':
this.disableSubmenu();
break;
case 'ArrowLeft':
// Either focus is currently on the host element or a child
if (event.target !== this.host) {
event.preventDefault();
event.stopPropagation();
this.host.focus();
this.disableSubmenu();
}
break;
case 'ArrowRight':
case 'Enter':
case ' ':
this.handleSubmenuEntry(event);
break;
default:
break;
}
};
private handleClick = (event: MouseEvent) => {
// Clicking on the item which heads the menu does nothing, otherwise hide submenu and propagate
if (event.target === this.host) {
event.preventDefault();
event.stopPropagation();
} else if (
event.target instanceof Element &&
(event.target.tagName === 'wa-menu-item' || event.target.role?.startsWith('menuitem'))
) {
this.disableSubmenu();
}
};
// Close this submenu on focus outside of the parent or any descendants.
private handleFocusOut = (event: FocusEvent) => {
if (event.relatedTarget && event.relatedTarget instanceof Element && this.host.contains(event.relatedTarget)) {
return;
}
this.disableSubmenu();
};
// Prevent the parent menu-item from getting focus on mouse movement on the submenu
private handlePopupMouseover = (event: MouseEvent) => {
event.stopPropagation();
};
// Set the safe triangle values for the submenu when the position changes
private handlePopupReposition = () => {
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
const menu = submenuSlot?.assignedElements({ flatten: true }).filter(el => el.localName === 'wa-menu')[0];
const isRtl = getComputedStyle(this.host).direction === 'rtl';
if (!menu) {
return;
}
const { left, top, width, height } = menu.getBoundingClientRect();
this.host.style.setProperty('--safe-triangle-submenu-start-x', `${isRtl ? left + width : left}px`);
this.host.style.setProperty('--safe-triangle-submenu-start-y', `${top}px`);
this.host.style.setProperty('--safe-triangle-submenu-end-x', `${isRtl ? left + width : left}px`);
this.host.style.setProperty('--safe-triangle-submenu-end-y', `${top + height}px`);
};
private setSubmenuState(state: boolean) {
if (this.popupRef.value) {
if (this.popupRef.value.active !== state) {
this.popupRef.value.active = state;
this.host.requestUpdate();
}
}
}
// Shows the submenu. Supports disabling the opening delay, e.g. for keyboard events that want to set the focus to the
// newly opened menu.
private enableSubmenu(delay = true) {
if (delay) {
window.clearTimeout(this.enableSubmenuTimer);
this.enableSubmenuTimer = window.setTimeout(() => {
this.setSubmenuState(true);
}, this.submenuOpenDelay);
} else {
this.setSubmenuState(true);
}
}
private disableSubmenu() {
window.clearTimeout(this.enableSubmenuTimer);
this.setSubmenuState(false);
}
// Calculate the space the top of a menu takes-up, for aligning the popup menu-item with the activating element.
private updateSkidding(): void {
// .computedStyleMap() not always available.
if (!this.host.parentElement?.computedStyleMap) {
return;
}
const styleMap: StylePropertyMapReadOnly = this.host.parentElement.computedStyleMap();
const attrs: string[] = ['padding-top', 'border-top-width', 'margin-top'];
const skidding = attrs.reduce((accumulator, attr) => {
const styleValue: CSSStyleValue = styleMap.get(attr) ?? new CSSUnitValue(0, 'px');
const unitValue = styleValue instanceof CSSUnitValue ? styleValue : new CSSUnitValue(0, 'px');
const pxValue = unitValue.to('px');
return accumulator - pxValue.value;
}, 0);
this.skidding = skidding;
}
isExpanded(): boolean {
return this.popupRef.value ? this.popupRef.value.active : false;
}
renderSubmenu() {
// Always render the slot, but conditionally render the outer <wa-popup>
if (!this.host.hasUpdated) {
return html` <slot name="submenu" hidden></slot> `;
}
const isRtl = getComputedStyle(this.host).direction === 'rtl';
return html`
<wa-popup
${ref(this.popupRef)}
placement=${isRtl ? 'left-start' : 'right-start'}
.anchor="${this.host}"
flip
flip-fallback-strategy="best-fit"
skidding="${this.skidding}"
auto-size="vertical"
auto-size-padding="10"
>
<slot name="submenu"></slot>
</wa-popup>
`;
}
}

View File

@@ -1,9 +0,0 @@
:host {
display: block;
color: var(--wa-color-text-quiet);
font-size: var(--wa-font-size-smaller);
font-weight: var(--wa-font-weight-semibold);
padding: 0.5em 2.25em;
-webkit-user-select: none;
user-select: none;
}

View File

@@ -1,15 +0,0 @@
import { expect } from '@open-wc/testing';
import { html } from 'lit';
import { fixtures } from '../../internal/test/fixture.js';
import type WaMenuLabel from './menu-label.js';
describe('<wa-menu-label>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('passes accessibility test', async () => {
const el = await fixture<WaMenuLabel>(html` <wa-menu-label>Test</wa-menu-label> `);
await expect(el).to.be.accessible();
});
});
}
});

View File

@@ -1,27 +0,0 @@
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import styles from './menu-label.css';
/**
* @summary Menu labels are used to describe a group of menu items.
* @documentation https://backers.webawesome.com/docs/components/menu-label
* @status stable
* @since 2.0
*
* @slot - The menu label's content.
*/
@customElement('wa-menu-label')
export default class WaMenuLabel extends WebAwesomeElement {
static css = styles;
render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-menu-label': WaMenuLabel;
}
}

View File

@@ -1,15 +0,0 @@
:host {
display: block;
position: relative;
text-align: start;
background-color: var(--wa-color-surface-raised);
border: var(--wa-border-style) var(--wa-border-width-s) var(--wa-color-surface-border);
border-radius: var(--wa-border-radius-m);
padding: 0.5em 0;
overflow: auto;
overscroll-behavior: none;
}
::slotted(wa-divider) {
--spacing: 0.5em;
}

View File

@@ -1,127 +0,0 @@
import { expect } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import { html } from 'lit';
import sinon from 'sinon';
import type { WaSelectEvent } from '../../events/select.js';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import type WaMenu from './menu.js';
describe('<wa-menu>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('emits wa-select with the correct event detail when clicking an item', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2">Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const item2 = menu.querySelectorAll('wa-menu-item')[1];
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect event detail emitted with wa-select');
}
});
menu.addEventListener('wa-select', selectHandler);
await clickOnElement(item2);
expect(selectHandler).to.have.been.calledOnce;
});
it('can be selected via keyboard', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2">Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const [item1, item2] = menu.querySelectorAll('wa-menu-item');
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect item selected');
}
});
menu.addEventListener('wa-select', selectHandler);
item1.focus();
await item1.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await sendKeys({ press: 'Enter' });
expect(selectHandler).to.have.been.calledOnce;
});
it('does not select disabled items when clicking', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2" disabled>Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const item2 = menu.querySelectorAll('wa-menu-item')[1];
const selectHandler = sinon.spy();
menu.addEventListener('wa-select', selectHandler);
await clickOnElement(item2);
expect(selectHandler).to.not.have.been.calledOnce;
});
it('does not select disabled items when pressing enter', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2" disabled>Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const [item1, item2] = menu.querySelectorAll('wa-menu-item');
const selectHandler = sinon.spy();
menu.addEventListener('wa-select', selectHandler);
item1.focus();
await item1.updateComplete;
await sendKeys({ press: 'ArrowDown' });
expect(document.activeElement).to.equal(item2);
await sendKeys({ press: 'Enter' });
await item2.updateComplete;
expect(selectHandler).to.not.have.been.called;
});
// @see https://github.com/shoelace-style/shoelace/issues/1596
it('Should fire "wa-select" when clicking an element within a menu-item', async () => {
// eslint-disable-next-line
const selectHandler = sinon.spy(() => {});
const menu: WaMenu = await fixture(html`
<wa-menu>
<wa-menu-item>
<span>Menu item</span>
</wa-menu-item>
</wa-menu>
`);
menu.addEventListener('wa-select', selectHandler);
const span = menu.querySelector('span')!;
await clickOnElement(span);
expect(selectHandler).to.have.been.calledOnce;
});
});
}
});

View File

@@ -1,172 +0,0 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { WaSelectEvent } from '../../events/select.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import sizeStyles from '../../styles/utilities/size.css';
import '../menu-item/menu-item.js';
import type WaMenuItem from '../menu-item/menu-item.js';
import styles from './menu.css';
export interface MenuSelectEventDetail {
item: WaMenuItem;
}
/**
* @summary Menus provide a list of options for the user to choose from.
* @documentation https://backers.webawesome.com/docs/components/menu
* @status stable
* @since 2.0
*
* @dependency wa-menu-item
*
* @slot - The menu's content, including menu items, menu labels, and dividers.
*
* @event {{ item: WaMenuItem }} wa-select - Emitted when a menu item is selected.
*/
@customElement('wa-menu')
export default class WaMenu extends WebAwesomeElement {
static css = [sizeStyles, styles];
/** The component's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
@query('slot') defaultSlot: HTMLSlotElement;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'menu');
}
private handleClick(event: MouseEvent) {
const menuItemTypes = ['menuitem', 'menuitemcheckbox'];
const target = event.composedPath().find((el: Element) => menuItemTypes.includes(el?.getAttribute?.('role') || ''));
if (!target) return;
// This isn't true. But we use it for TypeScript checks below.
const item = target as WaMenuItem;
if (item.type === 'checkbox') {
item.checked = !item.checked;
}
this.dispatchEvent(new WaSelectEvent({ item }));
}
private handleKeyDown(event: KeyboardEvent) {
// Make a selection when pressing enter or space
if (event.key === 'Enter' || event.key === ' ') {
const item = this.getCurrentItem();
event.preventDefault();
event.stopPropagation();
// Simulate a click to support @click handlers on menu items that also work with the keyboard
item?.click();
}
// Move the selection when pressing down or up
else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
const items = this.getAllItems();
const activeItem = this.getCurrentItem();
let index = activeItem ? items.indexOf(activeItem) : 0;
if (items.length > 0) {
event.preventDefault();
event.stopPropagation();
if (event.key === 'ArrowDown') {
index++;
} else if (event.key === 'ArrowUp') {
index--;
} else if (event.key === 'Home') {
index = 0;
} else if (event.key === 'End') {
index = items.length - 1;
}
if (index < 0) {
index = items.length - 1;
}
if (index > items.length - 1) {
index = 0;
}
this.setCurrentItem(items[index]);
items[index].focus();
}
}
}
private handleMouseDown(event: MouseEvent) {
const target = event.target as HTMLElement;
if (this.isMenuItem(target)) {
this.setCurrentItem(target as WaMenuItem);
}
}
private handleSlotChange() {
const items = this.getAllItems();
// Reset the roving tab index when the slotted items change
if (items.length > 0) {
this.setCurrentItem(items[0]);
}
}
private isMenuItem(item: HTMLElement) {
return (
item.tagName.toLowerCase() === 'wa-menu-item' ||
['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(item.getAttribute('role') ?? '')
);
}
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
getAllItems() {
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
if (el.inert || !this.isMenuItem(el)) {
return false;
}
return true;
}) as WaMenuItem[];
}
/**
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
*/
getCurrentItem() {
return this.getAllItems().find(i => i.getAttribute('tabindex') === '0');
}
/**
* @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
* `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
*/
setCurrentItem(item: WaMenuItem) {
const items = this.getAllItems();
// Update tab indexes
items.forEach(i => {
i.setAttribute('tabindex', i === item ? '0' : '-1');
});
}
render() {
return html`
<slot
@slotchange=${this.handleSlotChange}
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
@mousedown=${this.handleMouseDown}
></slot>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-menu': WaMenu;
}
}

View File

@@ -19,7 +19,6 @@ export type { WaMutationEvent } from './mutation.js';
export type { WaRemoveEvent } from './remove.js';
export type { WaRepositionEvent } from './reposition.js';
export type { WaResizeEvent } from './resize.js';
export type { WaSelectEvent } from './select.js';
export type { WaSelectionChangeEvent } from './selection-change.js';
export type { WaShowEvent } from './show.js';
export type { WaSlideChangeEvent } from './slide-change.js';

View File

@@ -1,20 +0,0 @@
import type WaMenuItem from '../components/menu-item/menu-item.js';
export class WaSelectEvent extends Event {
readonly detail;
constructor(detail: WaSelectEventDetail) {
super('wa-select', { bubbles: true, cancelable: false, composed: true });
this.detail = detail;
}
}
interface WaSelectEventDetail {
item: WaMenuItem;
}
declare global {
interface GlobalEventHandlersEventMap {
'wa-select': WaSelectEvent;
}
}