mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 12:09:26 +00:00
remove old dropdown, menu, menu item, menu label
This commit is contained in:
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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.
|
||||
:::
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 option’s 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user