mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-18 15:09:14 +00:00
Compare commits
21 Commits
subcompone
...
konnorroge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
359c2138cc | ||
|
|
b4f63dc934 | ||
|
|
ab5708aba3 | ||
|
|
628d070ba8 | ||
|
|
315634e123 | ||
|
|
3156a41d28 | ||
|
|
874adf7283 | ||
|
|
cab502e381 | ||
|
|
f92fd996c1 | ||
|
|
10ad44275c | ||
|
|
ee224382bc | ||
|
|
5802cc04f5 | ||
|
|
386b074af6 | ||
|
|
4f44369735 | ||
|
|
cce451c084 | ||
|
|
3cd1b7b093 | ||
|
|
4ce4c8b8d0 | ||
|
|
c70d2c3778 | ||
|
|
c1441abe15 | ||
|
|
9c9d1900dd | ||
|
|
72e485c08c |
@@ -121,20 +121,18 @@
|
||||
<li><a href="/docs/components/dialog/">Dialog</a></li>
|
||||
<li><a href="/docs/components/divider/">Divider</a></li>
|
||||
<li><a href="/docs/components/drawer/">Drawer</a></li>
|
||||
<li><a href="/docs/components/dropdown/">Dropdown</a></li>
|
||||
<li>
|
||||
<a href="/docs/components/dropdown">Dropdown</a>
|
||||
<ul>
|
||||
<li><a href="/docs/components/dropdown-item">Dropdown Item</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/docs/components/format-bytes/">Format Bytes</a></li>
|
||||
<li><a href="/docs/components/format-date/">Format Date</a></li>
|
||||
<li><a href="/docs/components/format-number/">Format Number</a></li>
|
||||
<li><a href="/docs/components/icon/">Icon</a></li>
|
||||
<li><a href="/docs/components/include/">Include</a></li>
|
||||
<li><a href="/docs/components/input/">Input</a></li>
|
||||
<li>
|
||||
<a href="/docs/components/menu/">Menu</a>
|
||||
<ul>
|
||||
<li><a href="/docs/components/menu-item/">Menu Item</a></li>
|
||||
<li><a href="/docs/components/menu-label/">Menu Label</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/docs/components/mutation-observer/">Mutation Observer</a></li>
|
||||
<li><a href="/docs/components/popover/">Popover</a></li>
|
||||
<li><a href="/docs/components/popup/">Popup</a></li>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Dropdown Item
|
||||
description: Description of component.
|
||||
layout: component
|
||||
---
|
||||
|
||||
This component must be used as a child of `<wa-dropdown>`. Please see the [Dropdown docs](/docs/components/dropdown) to see examples of this component in action.
|
||||
@@ -7,28 +7,38 @@ icon: dropdown
|
||||
|
||||
Dropdowns consist of a trigger and a panel. By default, activating the trigger will expose the panel and interacting outside of the panel will close it.
|
||||
|
||||
Dropdowns are designed to work well with [menus](/docs/components/menu) to provide a list of options the user can select from. However, dropdowns can also be used in lower-level applications (e.g. [color picker](/docs/components/color-picker)). The API gives you complete control over showing, hiding, and positioning the panel.
|
||||
Dropdowns are designed to work well with [dropdown items](/docs/components/dropdown-item) to provide a list of options the user can select from. However, dropdowns can also be used in lower-level applications. The API gives you complete control over showing, hiding, and positioning the panel.
|
||||
|
||||
```html {.example}
|
||||
<wa-dropdown>
|
||||
<wa-button slot="trigger" caret>Dropdown</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-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
|
||||
<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>
|
||||
|
||||
<wa-dropdown-item>
|
||||
<wa-icon slot="icon" name="scissors"></wa-icon>
|
||||
Cut
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item>
|
||||
<wa-icon slot="icon" name="copy"></wa-icon>
|
||||
Copy
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item>
|
||||
<wa-icon slot="icon" name="paste"></wa-icon>
|
||||
Paste
|
||||
</wa-dropdown-item>
|
||||
<wa-divider></wa-divider>
|
||||
<wa-dropdown-item>
|
||||
Show images
|
||||
<wa-dropdown-item slot="submenu" value="show-all-images">Show All Images</wa-dropdown-item>
|
||||
<wa-dropdown-item slot="submenu" value="show-thumbnails">Show Thumbnails</wa-dropdown-item>
|
||||
</wa-dropdown-item>
|
||||
<wa-divider></wa-divider>
|
||||
<wa-dropdown-item type="checkbox">Emoji Shortcuts<wa-dropdown-item>
|
||||
<wa-dropdown-item type="checkbox">Word Wrap</wa-dropdown-item>
|
||||
<wa-divider></wa-divider>
|
||||
<wa-dropdown-item variant="danger">
|
||||
<wa-icon slot="icon" name="trash"></wa-icon>
|
||||
Delete
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
```
|
||||
|
||||
@@ -36,17 +46,16 @@ Dropdowns are designed to work well with [menus](/docs/components/menu) to provi
|
||||
|
||||
### Getting the Selected Item
|
||||
|
||||
When dropdowns are used with [menus](/docs/components/menu), you can listen for the [`wa-select`](/docs/components/menu#events) event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands.
|
||||
When an item is selected, the `wa-select` event will be emitted by the dropdown. You can inspect `event.detail.item` to get a reference to the selected item. If you've provided a value for each [dropdown item](/docs/components/dropdown-item), it will be available in `event.detail.item.value`.
|
||||
|
||||
```html {.example}
|
||||
<div class="dropdown-selection">
|
||||
<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-button slot="trigger" caret>View</wa-button>
|
||||
<wa-dropdown-item value="full-screen">Enter full screen</wa-dropdown-item>
|
||||
<wa-dropdown-item value="actual">Actual size</wa-dropdown-item>
|
||||
<wa-dropdown-item value="zoom-in">Zoom in</wa-dropdown-item>
|
||||
<wa-dropdown-item value="zoom-out">Zoom out</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
</div>
|
||||
|
||||
@@ -55,53 +64,191 @@ When dropdowns are used with [menus](/docs/components/menu), you can listen for
|
||||
const dropdown = container.querySelector('wa-dropdown');
|
||||
|
||||
dropdown.addEventListener('wa-select', event => {
|
||||
const selectedItem = event.detail.item;
|
||||
console.log(selectedItem.value);
|
||||
console.log(event.detail.item.value);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Alternatively, you can listen for the `click` event on individual menu items. Note that, using this approach, disabled menu items will still emit a `click` event.
|
||||
:::info
|
||||
To keep the dropdown open after selection, call `event.preventDefault()` in the `wa-select` event's callback.
|
||||
:::
|
||||
|
||||
### Showing Icons
|
||||
|
||||
Use the `icon` slot to add icons to [dropdown items](/docs/components/dropdown-item). This works best with [icon](/docs/components/icon) elements.
|
||||
|
||||
```html {.example}
|
||||
<div class="dropdown-selection-alt">
|
||||
<wa-dropdown>
|
||||
<wa-button slot="trigger" caret>Edit</wa-button>
|
||||
|
||||
<wa-dropdown-item value="cut">
|
||||
<wa-icon slot="icon" name="scissors"></wa-icon>
|
||||
Cut
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item value="copy">
|
||||
<wa-icon slot="icon" name="copy"></wa-icon>
|
||||
Copy
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item value="paste">
|
||||
<wa-icon slot="icon" name="paste"></wa-icon>
|
||||
Paste
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item value="delete">
|
||||
<wa-icon slot="icon" name="trash"></wa-icon>
|
||||
Delete
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
```
|
||||
|
||||
### Showing Labels & Dividers
|
||||
|
||||
Use any heading, e.g. `<h1>`–`<h6>` to add labels and the [`<wa-divider>`](/docs/components/divider) element for separators.
|
||||
|
||||
```html {.example}
|
||||
<wa-dropdown>
|
||||
<wa-button slot="trigger" caret>Device</wa-button>
|
||||
|
||||
<h3>Type</h3>
|
||||
<wa-dropdown-item value="phone">Phone</wa-dropdown-item>
|
||||
<wa-dropdown-item value="tablet">Tablet</wa-dropdown-item>
|
||||
<wa-dropdown-item value="desktop">Desktop</wa-dropdown-item>
|
||||
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<wa-dropdown-item value="more">More options…</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
```
|
||||
|
||||
### Showing Details
|
||||
|
||||
Use the `details` slot to display details, such as keyboard shortcuts, inside [dropdown items](/docs/components/dropdown-item).
|
||||
|
||||
```html {.example}
|
||||
<wa-dropdown>
|
||||
<wa-button slot="trigger" caret>Message</wa-button>
|
||||
|
||||
<wa-dropdown-item value="reply">
|
||||
Reply
|
||||
<span slot="details">⌘R</span>
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item value="forward">
|
||||
Forward
|
||||
<span slot="details">⌘F</span>
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item value="move">
|
||||
Move
|
||||
<span slot="details">⌘M</span>
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<wa-dropdown-item value="archive">
|
||||
Archive
|
||||
<span slot="details">⌘A</span>
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item value="delete" variant="danger">
|
||||
Delete
|
||||
<span slot="details">Del</span>
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
```
|
||||
|
||||
### Checkable Items
|
||||
|
||||
You can turn a [dropdown item](/docs/components/dropdown-item) into a checkable option by setting `type="checkbox"`. Add the `checked` attribute to make it checked initially. When clicked, the item's checked state will toggle and the dropdown will close. You can cancel the `wa-select` event if you want to keep it open instead.
|
||||
|
||||
```html {.example}
|
||||
<div class="dropdown-checkboxes">
|
||||
<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-button slot="trigger" caret>View</wa-button>
|
||||
|
||||
<wa-dropdown-item type="checkbox" value="canvas" checked>Show canvas</wa-dropdown-item>
|
||||
<wa-dropdown-item type="checkbox" value="grid" checked>Show grid</wa-dropdown-item>
|
||||
<wa-dropdown-item type="checkbox" value="source">Show source</wa-dropdown-item>
|
||||
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<wa-dropdown-item value="preferences">Preferences…</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const container = document.querySelector('.dropdown-selection-alt');
|
||||
const cut = container.querySelector('wa-menu-item[value="cut"]');
|
||||
const copy = container.querySelector('wa-menu-item[value="copy"]');
|
||||
const paste = container.querySelector('wa-menu-item[value="paste"]');
|
||||
const container = document.querySelector('.dropdown-checkboxes');
|
||||
const dropdown = container.querySelector('wa-dropdown');
|
||||
|
||||
cut.addEventListener('click', () => console.log('cut'));
|
||||
copy.addEventListener('click', () => console.log('copy'));
|
||||
paste.addEventListener('click', () => console.log('paste'));
|
||||
dropdown.addEventListener('wa-select', event => {
|
||||
if (event.detail.item.type === 'checkbox') {
|
||||
// Checkbox
|
||||
console.log(event.detail.item.value, event.detail.item.checked ? 'checked' : 'unchecked');
|
||||
} else {
|
||||
// Not a checkbox
|
||||
console.log(event.detail.item.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
:::info
|
||||
When a checkable option exists anywhere in the dropdown, all items will receive additional padding so they align properly.
|
||||
:::
|
||||
|
||||
### Destructive Items
|
||||
|
||||
Add `variant="danger"` to any [dropdown item](/docs/components/dropdown-item) to highlight that it's a dangerous action.
|
||||
|
||||
```html {.example}
|
||||
<wa-dropdown>
|
||||
<wa-button slot="trigger" caret>Project</wa-button>
|
||||
|
||||
<wa-dropdown-item value="share">
|
||||
<wa-icon slot="icon" name="share"></wa-icon>
|
||||
Share
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item value="preferences">
|
||||
<wa-icon slot="icon" name="gear"></wa-icon>
|
||||
Preferences
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<h3>Danger zone</h3>
|
||||
|
||||
<wa-dropdown-item value="archive">
|
||||
<wa-icon slot="icon" name="archive"></wa-icon>
|
||||
Archive
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item value="delete" variant="danger">
|
||||
<wa-icon slot="icon" name="trash"></wa-icon>
|
||||
Delete
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
```
|
||||
|
||||
### Placement
|
||||
|
||||
The preferred placement of the dropdown can be set with the `placement` attribute. Note that the actual position may vary to ensure the panel remains in the viewport.
|
||||
|
||||
```html {.example}
|
||||
<wa-dropdown placement="top-start">
|
||||
<wa-button slot="trigger" caret>Edit</wa-button>
|
||||
<wa-menu>
|
||||
<wa-menu-item>Cut</wa-menu-item>
|
||||
<wa-menu-item>Copy</wa-menu-item>
|
||||
<wa-menu-item>Paste</wa-menu-item>
|
||||
<wa-divider></wa-divider>
|
||||
<wa-menu-item>Find</wa-menu-item>
|
||||
<wa-menu-item>Replace</wa-menu-item>
|
||||
</wa-menu>
|
||||
<wa-dropdown placement="right-start">
|
||||
<wa-button slot="trigger">
|
||||
File formats
|
||||
<wa-icon slot="suffix" name="chevron-right"></wa-icon>
|
||||
</wa-button>
|
||||
|
||||
<wa-dropdown-item value="pdf">PDF Document</wa-dropdown-item>
|
||||
<wa-dropdown-item value="docx">Word Document</wa-dropdown-item>
|
||||
<wa-dropdown-item value="xlsx">Excel Spreadsheet</wa-dropdown-item>
|
||||
<wa-dropdown-item value="pptx">PowerPoint Presentation</wa-dropdown-item>
|
||||
<wa-dropdown-item value="txt">Plain Text</wa-dropdown-item>
|
||||
<wa-dropdown-item value="json">JSON File</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
```
|
||||
|
||||
@@ -112,71 +259,111 @@ The distance from the panel to the trigger can be customized using the `distance
|
||||
```html {.example}
|
||||
<wa-dropdown distance="30">
|
||||
<wa-button slot="trigger" caret>Edit</wa-button>
|
||||
<wa-menu>
|
||||
<wa-menu-item>Cut</wa-menu-item>
|
||||
<wa-menu-item>Copy</wa-menu-item>
|
||||
<wa-menu-item>Paste</wa-menu-item>
|
||||
<wa-divider></wa-divider>
|
||||
<wa-menu-item>Find</wa-menu-item>
|
||||
<wa-menu-item>Replace</wa-menu-item>
|
||||
</wa-menu>
|
||||
|
||||
<wa-dropdown-item>Cut</wa-dropdown-item>
|
||||
<wa-dropdown-item>Copy</wa-dropdown-item>
|
||||
<wa-dropdown-item>Paste</wa-dropdown-item>
|
||||
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<wa-dropdown-item>Find</wa-dropdown-item>
|
||||
<wa-dropdown-item>Replace</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
```
|
||||
|
||||
### Skidding
|
||||
### Offset
|
||||
|
||||
The offset of the panel along the trigger can be customized using the `skidding` attribute. This value is specified in pixels.
|
||||
The offset of the panel along the trigger can be customized using the `offset` attribute. This value is specified in pixels.
|
||||
|
||||
```html {.example}
|
||||
<wa-dropdown skidding="30">
|
||||
<wa-dropdown offset="30">
|
||||
<wa-button slot="trigger" caret>Edit</wa-button>
|
||||
<wa-menu>
|
||||
<wa-menu-item>Cut</wa-menu-item>
|
||||
<wa-menu-item>Copy</wa-menu-item>
|
||||
<wa-menu-item>Paste</wa-menu-item>
|
||||
<wa-divider></wa-divider>
|
||||
<wa-menu-item>Find</wa-menu-item>
|
||||
<wa-menu-item>Replace</wa-menu-item>
|
||||
</wa-menu>
|
||||
|
||||
<wa-dropdown-item>Cut</wa-dropdown-item>
|
||||
<wa-dropdown-item>Copy</wa-dropdown-item>
|
||||
<wa-dropdown-item>Paste</wa-dropdown-item>
|
||||
|
||||
<wa-divider></wa-divider>
|
||||
|
||||
<wa-dropdown-item>Find</wa-dropdown-item>
|
||||
<wa-dropdown-item>Replace</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
```
|
||||
|
||||
### Submenus
|
||||
|
||||
To create a submenu, nest an `<wa-menu slot="submenu">` element in a [menu item](/docs/components/menu-item).
|
||||
To create submenus, nest [dropdown items](/docs/components/dropdown-item) inside of a dropdown item and assign `slot="submenu"` to each one. You can also add [dividers](/docs/components/divider) as needed.
|
||||
|
||||
```html {.example}
|
||||
<wa-dropdown>
|
||||
<wa-button slot="trigger" caret>Edit</wa-button>
|
||||
<div class="dropdown-submenus">
|
||||
<wa-dropdown>
|
||||
<wa-button slot="trigger" caret>Export</wa-button>
|
||||
|
||||
<wa-dropdown-item>
|
||||
Documents
|
||||
<wa-dropdown-item slot="submenu" value="pdf">PDF</wa-dropdown-item>
|
||||
<wa-dropdown-item slot="submenu" value="docx">Word Document</wa-dropdown-item>
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item>
|
||||
Spreadsheets
|
||||
<wa-dropdown-item slot="submenu">
|
||||
Excel Formats
|
||||
<wa-dropdown-item slot="submenu" value="xlsx">Excel (.xlsx)</wa-dropdown-item>
|
||||
<wa-dropdown-item slot="submenu" value="xls">Excel 97-2003 (.xls)</wa-dropdown-item>
|
||||
<wa-dropdown-item slot="submenu" value="csv">CSV (.csv)</wa-dropdown-item>
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item slot="submenu">
|
||||
Other Formats
|
||||
<wa-dropdown-item slot="submenu" value="ods">OpenDocument (.ods)</wa-dropdown-item>
|
||||
<wa-dropdown-item slot="submenu" value="tsv">Tab-separated (.tsv)</wa-dropdown-item>
|
||||
<wa-dropdown-item slot="submenu" value="json">JSON (.json)</wa-dropdown-item>
|
||||
</wa-dropdown-item>
|
||||
|
||||
<wa-dropdown-item slot="submenu" value="numbers">Apple Numbers</wa-dropdown-item>
|
||||
</wa-dropdown-item>
|
||||
|
||||
<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>
|
||||
</wa-dropdown>
|
||||
|
||||
<wa-dropdown-item>
|
||||
Options
|
||||
<wa-dropdown-item slot="submenu" type="checkbox" value="compress">Compress files</wa-dropdown-item>
|
||||
<wa-dropdown-item slot="submenu" type="checkbox" checked value="metadata">Include metadata</wa-dropdown-item>
|
||||
<wa-dropdown-item slot="submenu" type="checkbox" value="password">Password protect</wa-dropdown-item>
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const container = document.querySelector('.dropdown-submenus');
|
||||
const dropdown = container.querySelector('wa-dropdown');
|
||||
|
||||
dropdown.addEventListener('wa-select', event => {
|
||||
console.log(event.detail.item.value);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
:::info
|
||||
Dropdown items that have a submenu will not dispatch the `wa-select` event. However, items inside the submenu will, unless they also have a submenu.
|
||||
:::
|
||||
|
||||
:::warning
|
||||
As a UX best practice, avoid using more than one level of submenu when possible.
|
||||
:::
|
||||
|
||||
### Disabling Items
|
||||
|
||||
Add the `disabled` attribute to any [dropdown item](/docs/components/dropdown-item) to disable it.
|
||||
|
||||
```html {.example}
|
||||
<wa-dropdown>
|
||||
<wa-button slot="trigger" caret>Payment method</wa-button>
|
||||
|
||||
<wa-dropdown-item value="cash">Cash</wa-dropdown-item>
|
||||
<wa-dropdown-item value="check" disabled>Personal check</wa-dropdown-item>
|
||||
<wa-dropdown-item value="credit">Credit card</wa-dropdown-item>
|
||||
<wa-dropdown-item value="gift-card">Gift card</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -49,6 +49,9 @@ During the alpha period, things might break! We take breaking changes very serio
|
||||
- Improved the styling API to be consistent and more powerful (no more browser-specific selectors and pseudo elements to style)
|
||||
- Updated to use consistent `with-*` attribute naming pattern
|
||||
- 🚨 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]
|
||||
|
||||
@@ -149,13 +149,14 @@ export async function build(options = {}) {
|
||||
if (process.env.ROOT_DIR) {
|
||||
process.chdir(process.env.ROOT_DIR);
|
||||
}
|
||||
execSync(`tsc --project ./tsconfig.prod.json --outdir "${getCdnDir()}"`);
|
||||
execSync(`tsc --project ./tsconfig.prod.json --outdir "${getCdnDir()}"`, { stdio: "inherit" });
|
||||
process.chdir(cwd);
|
||||
} catch (error) {
|
||||
process.chdir(cwd);
|
||||
if (!isDeveloping) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return Promise.reject(error.stdout);
|
||||
}
|
||||
|
||||
|
||||
@@ -172,30 +172,6 @@ describe('<wa-breadcrumb-item>', () => {
|
||||
expect(childNodes.length).to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when rendering a wa-dropdown in the default slot', () => {
|
||||
it('should not render a link or button tag, but a div wrapper', async () => {
|
||||
const el = await fixture<WaBreadcrumbItem>(html`
|
||||
<wa-breadcrumb-item>
|
||||
<wa-dropdown>
|
||||
<wa-button slot="trigger" size="small" circle>
|
||||
<wa-icon label="More options" name="ellipsis"></wa-icon>
|
||||
</wa-button>
|
||||
<wa-menu>
|
||||
<wa-menu-item type="checkbox" checked>Web Design</wa-menu-item>
|
||||
<wa-menu-item type="checkbox">Web Development</wa-menu-item>
|
||||
<wa-menu-item type="checkbox">Marketing</wa-menu-item>
|
||||
</wa-menu>
|
||||
</wa-dropdown>
|
||||
</wa-breadcrumb-item>
|
||||
`);
|
||||
|
||||
await expect(el).to.be.accessible();
|
||||
expect(el.shadowRoot!.querySelector('a')).to.be.null;
|
||||
expect(el.shadowRoot!.querySelector('button')).to.be.null;
|
||||
expect(el.shadowRoot!.querySelector('.label-dropdown')).not.to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
.color-picker {
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--border-radius);
|
||||
border-style: var(--border-style);
|
||||
border-width: var(--border-width);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: var(--wa-shadow-l);
|
||||
color: var(--color);
|
||||
font: inherit;
|
||||
user-select: none;
|
||||
|
||||
@@ -329,13 +329,6 @@ describe('<wa-color-picker>', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should render in a dropdown', async () => {
|
||||
const el = await fixture<WaColorPicker>(html` <wa-color-picker></wa-color-picker> `);
|
||||
const dropdown = el.shadowRoot!.querySelector('wa-dropdown');
|
||||
|
||||
expect(dropdown).to.exist;
|
||||
});
|
||||
|
||||
it('should show opacity slider when opacity is enabled', async () => {
|
||||
const el = await fixture<WaColorPicker>(html` <wa-color-picker opacity></wa-color-picker> `);
|
||||
const opacitySlider = el.shadowRoot!.querySelector('[part*="opacity-slider"]')!;
|
||||
@@ -368,7 +361,7 @@ describe('<wa-color-picker>', () => {
|
||||
<button type="button">Click me</button>
|
||||
</div>
|
||||
`);
|
||||
const colorPicker = el.querySelector('wa-color-picker')!;
|
||||
const colorPicker = el.querySelector<WaColorPicker>('wa-color-picker')!;
|
||||
const trigger = colorPicker.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
|
||||
const button = el.querySelector('button')!;
|
||||
const focusHandler = sinon.spy();
|
||||
@@ -456,7 +449,7 @@ describe('<wa-color-picker>', () => {
|
||||
</form>
|
||||
`);
|
||||
const button = form.querySelector('wa-button')!;
|
||||
const colorPicker = form.querySelector('wa-color-picker')!;
|
||||
const colorPicker = form.querySelector<WaColorPicker>('wa-color-picker')!;
|
||||
colorPicker.value = '#000000';
|
||||
|
||||
await colorPicker.updateComplete;
|
||||
|
||||
@@ -6,7 +6,9 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { WaInvalidEvent } from '../../events/invalid.js';
|
||||
import { animateWithClass } from '../../internal/animate.js';
|
||||
import { drag } from '../../internal/drag.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { RequiredValidator } from '../../internal/validators/required-validator.js';
|
||||
@@ -18,11 +20,11 @@ import visuallyHidden from '../../styles/utilities/visually-hidden.css';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import '../button-group/button-group.js';
|
||||
import '../button/button.js';
|
||||
import '../dropdown/dropdown.js';
|
||||
import type WaDropdown from '../dropdown/dropdown.js';
|
||||
import '../icon/icon.js';
|
||||
import '../input/input.js';
|
||||
import type WaInput from '../input/input.js';
|
||||
import '../popup/popup.js';
|
||||
import type WaPopup from '../popup/popup.js';
|
||||
import styles from './color-picker.css';
|
||||
|
||||
interface EyeDropperConstructor {
|
||||
@@ -43,8 +45,8 @@ declare const EyeDropper: EyeDropperConstructor;
|
||||
*
|
||||
* @dependency wa-button
|
||||
* @dependency wa-button-group
|
||||
* @dependency wa-dropdown
|
||||
* @dependency wa-input
|
||||
* @dependency wa-popup
|
||||
* @dependency wa-visually-hidden
|
||||
*
|
||||
* @slot label - The color picker's form label. Alternatively, you can use the `label` attribute.
|
||||
@@ -125,7 +127,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
|
||||
// or is the new behavior okay?
|
||||
get validationTarget() {
|
||||
// This puts the popup on the element only if the color picker is expanded.
|
||||
if (this.dropdown?.open) {
|
||||
if (this.popup?.active) {
|
||||
return this.input;
|
||||
}
|
||||
|
||||
@@ -134,7 +136,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
|
||||
return this.trigger;
|
||||
}
|
||||
|
||||
@query('.color-dropdown') dropdown: WaDropdown;
|
||||
@query('.color-popup') popup: WaPopup;
|
||||
@query('[part~="preview"]') previewButton: HTMLButtonElement;
|
||||
@query('[part~="trigger"]') trigger: HTMLButtonElement;
|
||||
|
||||
@@ -210,6 +212,12 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
|
||||
/** Disables the color picker. */
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the popup is open. You can toggle this attribute to show and hide the popup, or you
|
||||
* can use the `show()` and `hide()` methods and this attribute will reflect the popup's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/** Shows the opacity slider. Enabling this will cause the formatted value to be HEXA, RGBA, or HSLA. */
|
||||
@property({ type: Boolean }) opacity = false;
|
||||
|
||||
@@ -790,8 +798,8 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
|
||||
elementToBlur.blur();
|
||||
}
|
||||
|
||||
if (this.dropdown?.open) {
|
||||
this.dropdown.hide();
|
||||
if (this.popup?.active) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,10 +847,10 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
// This won't get called when a form is submitted. This is only for manual calls.
|
||||
if (!this.validity.valid && !this.dropdown.open) {
|
||||
// Show the dropdown so the browser can focus on it
|
||||
if (!this.validity.valid && !this.open) {
|
||||
// Show the popup so the browser can focus on it
|
||||
this.addEventListener('wa-after-show', this.reportValidityAfterShow, { once: true });
|
||||
this.dropdown.show();
|
||||
this.show();
|
||||
|
||||
if (!this.disabled) {
|
||||
// By standards we have to emit a `wa-invalid` event here synchronously.
|
||||
@@ -867,6 +875,158 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
|
||||
this.hasEyeDropper = 'EyeDropper' in window;
|
||||
}
|
||||
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape is pressed inside an open popup. We need to listen on the panel itself and stop propagation
|
||||
// in case any ancestors are also listening for this key.
|
||||
if (this.open && event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
this.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape or tab is pressed
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
event.stopPropagation();
|
||||
this.focus();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tabbing
|
||||
if (event.key === 'Tab') {
|
||||
// Tabbing outside of the containing element closes the panel
|
||||
//
|
||||
// If the popup is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
|
||||
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
|
||||
setTimeout(() => {
|
||||
const activeElement =
|
||||
this.getRootNode() instanceof ShadowRoot
|
||||
? document.activeElement?.shadowRoot?.activeElement
|
||||
: document.activeElement;
|
||||
|
||||
if (!this || activeElement?.closest(this.tagName.toLowerCase()) !== this) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentMouseDown = (event: MouseEvent) => {
|
||||
// Close when clicking outside of the popup panel and trigger
|
||||
const path = event.composedPath();
|
||||
|
||||
// Check if click is inside the popup panel or the trigger element specifically
|
||||
const isInsideRelevantArea = path.some(
|
||||
element => element instanceof Element && (element.closest('.color-picker') || element === this.trigger),
|
||||
);
|
||||
|
||||
if (this && !isInsideRelevantArea) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
handleTriggerClick() {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async handleTriggerKeyDown(event: KeyboardEvent) {
|
||||
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
|
||||
// key again to hide the menu in case they don't want to make a selection.
|
||||
if ([' ', 'Enter'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
this.handleTriggerClick();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerKeyUp(event: KeyboardEvent) {
|
||||
// Prevent space from triggering a click event in Firefox
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
updateAccessibleTrigger() {
|
||||
const accessibleTrigger = this.trigger;
|
||||
|
||||
if (accessibleTrigger) {
|
||||
accessibleTrigger.setAttribute('aria-haspopup', 'true');
|
||||
accessibleTrigger.setAttribute('aria-expanded', this.open ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the color picker panel. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'wa-after-show');
|
||||
}
|
||||
|
||||
/** Hides the color picker panel */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'wa-after-hide');
|
||||
}
|
||||
|
||||
addOpenListeners() {
|
||||
this.base.addEventListener('keydown', this.handleKeyDown);
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
removeOpenListeners() {
|
||||
if (this.base) {
|
||||
this.base.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.disabled) {
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateAccessibleTrigger();
|
||||
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.dispatchEvent(new CustomEvent('wa-show'));
|
||||
|
||||
this.addOpenListeners();
|
||||
await this.updateComplete;
|
||||
this.base.hidden = false;
|
||||
this.popup.active = true;
|
||||
await animateWithClass(this.popup.popup, 'show-with-scale');
|
||||
this.dispatchEvent(new CustomEvent('wa-after-show'));
|
||||
} else {
|
||||
// Hide
|
||||
this.dispatchEvent(new CustomEvent('wa-hide'));
|
||||
|
||||
this.removeOpenListeners();
|
||||
await animateWithClass(this.popup.popup, 'hide-with-scale');
|
||||
this.base.hidden = true;
|
||||
this.popup.active = false;
|
||||
this.dispatchEvent(new CustomEvent('wa-after-hide'));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = !this.hasUpdated ? this.withLabel : this.withLabel || this.hasSlotController.test('label');
|
||||
const hasHintSlot = !this.hasUpdated ? this.withHint : this.withHint || this.hasSlotController.test('hint');
|
||||
@@ -1095,82 +1255,64 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Render as a dropdown
|
||||
// Render with popup
|
||||
return html`
|
||||
<wa-dropdown
|
||||
class="color-dropdown"
|
||||
<div
|
||||
class=${classMap({
|
||||
container: true,
|
||||
'form-control': true,
|
||||
'form-control-has-label': hasLabel,
|
||||
})}
|
||||
part="trigger-container form-control"
|
||||
>
|
||||
<div part="form-control-label" class="label" id="form-control-label">
|
||||
<slot name="label">${this.label}</slot>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="trigger"
|
||||
part="trigger form-control-input"
|
||||
class=${classMap({
|
||||
trigger: true,
|
||||
'trigger-empty': this.isEmpty,
|
||||
'transparent-bg': true,
|
||||
'form-control-input': true,
|
||||
})}
|
||||
style=${styleMap({
|
||||
color: this.getHexString(this.hue, this.saturation, this.brightness, this.alpha),
|
||||
})}
|
||||
type="button"
|
||||
aria-labelledby="form-control-label"
|
||||
aria-describedby="hint"
|
||||
.disabled=${this.disabled}
|
||||
@click=${this.handleTriggerClick}
|
||||
@keydown=${this.handleTriggerKeyDown}
|
||||
@keyup=${this.handleTriggerKeyUp}
|
||||
></button>
|
||||
|
||||
<slot
|
||||
name="hint"
|
||||
part="hint"
|
||||
class=${classMap({
|
||||
'has-slotted': hasHint,
|
||||
})}
|
||||
>${this.hint}</slot
|
||||
>
|
||||
</div>
|
||||
|
||||
<wa-popup
|
||||
class="color-popup"
|
||||
anchor="trigger"
|
||||
placement="bottom-start"
|
||||
distance="0"
|
||||
skidding="0"
|
||||
sync="width"
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
.containingElement=${this}
|
||||
?disabled=${this.disabled}
|
||||
@wa-after-show=${this.handleAfterShow}
|
||||
@wa-after-hide=${this.handleAfterHide}
|
||||
>
|
||||
<div
|
||||
class=${classMap({
|
||||
container: true,
|
||||
'form-control': true,
|
||||
'form-control-has-label': hasLabel,
|
||||
})}
|
||||
part="trigger-container form-control"
|
||||
slot="trigger"
|
||||
@click=${(e: Event) => {
|
||||
const composedPath = e.composedPath();
|
||||
const triggerButton = this.triggerButton;
|
||||
const triggerLabel = this.triggerLabel;
|
||||
const buttonOrLabelClicked = composedPath.find(el => el === triggerButton || el === triggerLabel);
|
||||
|
||||
if (buttonOrLabelClicked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop clicks from bubbling on anything except the button and the label. This is a hacky work around i may come to regret, but this "fixes" the issue of `<wa-dropdown>` expecting all children in the "trigger slot" to open the trigger. [Konnor]
|
||||
e.stopImmediatePropagation();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.dropdown.open) {
|
||||
this.dropdown.hide();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div part="form-control-label" class="label" id="form-control-label">
|
||||
<slot name="label">${this.label}</slot>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="trigger"
|
||||
part="trigger form-control-input"
|
||||
class=${classMap({
|
||||
trigger: true,
|
||||
'trigger-empty': this.isEmpty,
|
||||
'transparent-bg': true,
|
||||
'form-control-input': true,
|
||||
})}
|
||||
style=${styleMap({
|
||||
color: this.getHexString(this.hue, this.saturation, this.brightness, this.alpha),
|
||||
})}
|
||||
type="button"
|
||||
aria-labelledby="form-control-label"
|
||||
aria-describedby="hint"
|
||||
.disabled=${this.disabled}
|
||||
></button>
|
||||
|
||||
<slot
|
||||
name="hint"
|
||||
part="hint"
|
||||
class=${classMap({
|
||||
'has-slotted': hasHint,
|
||||
})}
|
||||
>${this.hint}</slot
|
||||
>
|
||||
</div>
|
||||
${colorPicker}
|
||||
</wa-dropdown>
|
||||
</wa-popup>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'wa-color-picker': WaColorPicker;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
:host {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
padding: 0.33em 1em;
|
||||
border-radius: var(--wa-border-radius-s);
|
||||
isolation: isolate;
|
||||
color: var(--wa-color-neutral-on-quiet);
|
||||
line-height: var(--wa-line-height-normal);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
100ms background-color ease,
|
||||
100ms color ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:host(:hover:not(:state(disabled))) {
|
||||
background-color: var(--wa-color-neutral-fill-quiet);
|
||||
color: var(--wa-color-neutral-on-quiet);
|
||||
}
|
||||
}
|
||||
|
||||
:host(:focus-visible) {
|
||||
z-index: 1;
|
||||
outline: var(--wa-color-brand-border-loud);
|
||||
outline-offset: var(--wa-focus-ring-offset);
|
||||
background-color: var(--wa-color-neutral-fill-quiet);
|
||||
color: var(--wa-color-neutral-on-quiet);
|
||||
}
|
||||
|
||||
:host(:state(disabled)) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
:host([size='small']) {
|
||||
font-size: var(--wa-font-size-s);
|
||||
}
|
||||
|
||||
:host([size='medium']) {
|
||||
font-size: var(--wa-font-size-m);
|
||||
}
|
||||
|
||||
:host([size='large']) {
|
||||
font-size: var(--wa-font-size-l);
|
||||
}
|
||||
|
||||
/* Danger variant */
|
||||
:host([variant='danger']),
|
||||
:host([variant='danger']) #details {
|
||||
color: var(--wa-color-danger-on-quiet);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:host([variant='danger']:hover) {
|
||||
background-color: var(--wa-color-danger-fill-quiet);
|
||||
color: var(--wa-color-danger-on-quiet);
|
||||
}
|
||||
}
|
||||
|
||||
:host([variant='danger']:focus-visible) {
|
||||
background-color: var(--wa-color-danger-fill-quiet);
|
||||
color: var(--wa-color-danger-on-quiet);
|
||||
}
|
||||
|
||||
:host([checkbox-adjacent]) {
|
||||
padding-inline-start: 2em;
|
||||
}
|
||||
|
||||
/* Only add padding when item actually has a submenu */
|
||||
:host([submenu-adjacent]:not(:state(has-submenu))) #details {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
:host(:state(has-submenu)[submenu-adjacent]) #details {
|
||||
padding-inline-end: 1.75em;
|
||||
}
|
||||
|
||||
#check {
|
||||
visibility: hidden;
|
||||
margin-inline-start: -1.25em;
|
||||
margin-inline-end: 0.25em;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
:host(:state(checked)) #check {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
#icon ::slotted(*) {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
margin-inline-end: 0.5em !important;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
#label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#details {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
color: var(--wa-color-neutral-border-normal);
|
||||
font-size: 0.933334em !important;
|
||||
}
|
||||
|
||||
#details ::slotted(*) {
|
||||
margin-inline-start: 2em !important;
|
||||
}
|
||||
|
||||
/* Submenu indicator icon */
|
||||
#submenu-indicator {
|
||||
position: absolute;
|
||||
inset-inline-end: 0.25em;
|
||||
color: var(--wa-color-neutral-border-normal);
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
/* Flip chevron icon when RTL */
|
||||
:host(:dir(rtl)) #submenu-indicator {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* Submenu styles */
|
||||
#submenu {
|
||||
display: flex;
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
margin: 0;
|
||||
padding: 0.25em;
|
||||
border: var(--wa-border-style) var(--wa-border-width-s) var(--wa-color-neutral-border-quiet);
|
||||
border-radius: var(--wa-border-radius-m);
|
||||
background-color: var(--wa-color-surface-default);
|
||||
box-shadow: var(--wa-shadow-l);
|
||||
color: var(--wa-color-neutral-on-quiet);
|
||||
text-align: start;
|
||||
user-select: none;
|
||||
|
||||
/* Override default popover styles */
|
||||
&[popover] {
|
||||
margin: 0;
|
||||
inset: auto;
|
||||
padding: 0.25em;
|
||||
overflow: visible;
|
||||
border-radius: var(--wa-border-radius-m);
|
||||
}
|
||||
|
||||
&.show {
|
||||
animation: submenu-show var(--show-duration, 50ms) ease;
|
||||
}
|
||||
|
||||
&.hide {
|
||||
animation: submenu-show var(--show-duration, 50ms) ease reverse;
|
||||
}
|
||||
|
||||
/* Submenu placement transform origins */
|
||||
&[data-placement^='top'] {
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
&[data-placement^='bottom'] {
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
&[data-placement^='left'] {
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
&[data-placement^='right'] {
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
&[data-placement='left-start'] {
|
||||
transform-origin: right top;
|
||||
}
|
||||
|
||||
&[data-placement='left-end'] {
|
||||
transform-origin: right bottom;
|
||||
}
|
||||
|
||||
&[data-placement='right-start'] {
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
&[data-placement='right-end'] {
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
|
||||
/* Safe triangle styling */
|
||||
&::before {
|
||||
display: none;
|
||||
z-index: 9;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: transparent;
|
||||
content: '';
|
||||
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)
|
||||
);
|
||||
pointer-events: auto; /* Enable mouse events on the triangle */
|
||||
}
|
||||
|
||||
&[data-visible]::before {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
::slotted(wa-dropdown-item) {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
::slotted(wa-divider) {
|
||||
--spacing: 0.25em;
|
||||
}
|
||||
|
||||
@keyframes submenu-show {
|
||||
from {
|
||||
scale: 0.9;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
scale: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
|
||||
describe('<wa-dropdown-item>', () => {
|
||||
it('should render a component', async () => {
|
||||
const el = await fixture(html` <wa-dropdown-item></wa-dropdown-item> `);
|
||||
|
||||
expect(el).to.exist;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { PropertyValues } from 'lit';
|
||||
import { html } from 'lit';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { animateWithClass } from '../../internal/animate.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import WebAwesomeElement from '../../internal/webawesome-element.js';
|
||||
import styles from './dropdown-item.css';
|
||||
|
||||
/**
|
||||
* @summary Represents an individual item within a dropdown menu, supporting standard items, checkboxes, and submenus.
|
||||
* @documentation https://backers.webawesome.com/docs/components/dropdown-item
|
||||
* @status experimental
|
||||
* @since 3.0
|
||||
*
|
||||
* @dependency wa-icon
|
||||
*
|
||||
* @event blur - Emitted when the dropdown item loses focus.
|
||||
* @event focus - Emitted when the dropdown item gains focus.
|
||||
*
|
||||
* @slot - The dropdown item's label.
|
||||
* @slot icon - An optional icon to display before the label.
|
||||
* @slot details - Additional content or details to display after the label.
|
||||
* @slot submenu - Submenu items, typically `<wa-dropdown-item>` elements, to create a nested menu.
|
||||
*
|
||||
* @csspart checkmark - The checkmark icon (a `<wa-icon>` element) when the item is a checkbox.
|
||||
* @csspart icon - The container for the icon slot.
|
||||
* @csspart label - The container for the label slot.
|
||||
* @csspart details - The container for the details slot.
|
||||
* @csspart submenu-icon - The submenu indicator icon (a `<wa-icon>` element).
|
||||
* @csspart submenu - The submenu container.
|
||||
*/
|
||||
@customElement('wa-dropdown-item')
|
||||
export default class WaDropdownItem extends WebAwesomeElement {
|
||||
static css = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
|
||||
|
||||
@query('#submenu') submenuElement: HTMLDivElement;
|
||||
|
||||
/** @internal The controller will set this property to true when the item is active. */
|
||||
@property({ type: Boolean }) active = false;
|
||||
|
||||
/** The type of menu item to render. */
|
||||
@property({ reflect: true }) variant: 'danger' | 'default' = 'default';
|
||||
|
||||
/**
|
||||
* @internal The dropdown item's size.
|
||||
*/
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/**
|
||||
* @internal The controller will set this property to true when at least one checkbox exists in the dropdown. This
|
||||
* allows non-checkbox items to draw additional space to align properly with checkbox items.
|
||||
*/
|
||||
@property({ attribute: 'checkbox-adjacent', type: Boolean, reflect: true }) checkboxAdjacent = false;
|
||||
|
||||
/**
|
||||
* @internal The controller will set this property to true when at least one item with a submenu exists in the
|
||||
* dropdown. This allows non-submenu items to draw additional space to align properly with items that have submenus.
|
||||
*/
|
||||
@property({ attribute: 'submenu-adjacent', type: Boolean, reflect: true }) submenuAdjacent = false;
|
||||
|
||||
/**
|
||||
* An optional value for the menu item. This is useful for determining which item was selected when listening to the
|
||||
* dropdown's `wa-select` event.
|
||||
*/
|
||||
@property() value: string;
|
||||
|
||||
/** Set to `checkbox` to make the item a checkbox. */
|
||||
@property({ reflect: true }) type: 'normal' | 'checkbox' = 'normal';
|
||||
|
||||
/** Set to true to check the dropdown item. Only valid when `type` is `checkbox`. */
|
||||
@property({ type: Boolean }) checked = false;
|
||||
|
||||
/** Disables the dropdown item. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Whether the submenu is currently open. */
|
||||
@property({ type: Boolean, reflect: true }) submenuOpen = false;
|
||||
|
||||
/** @internal Store whether this item has a submenu */
|
||||
@state() hasSubmenu = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
|
||||
this.shadowRoot!.addEventListener('slotchange', this.handleSlotChange);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.closeSubmenu();
|
||||
this.removeEventListener('mouseenter', this.handleMouseEnter);
|
||||
this.shadowRoot!.removeEventListener('slotchange', this.handleSlotChange);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.setAttribute('tabindex', '-1');
|
||||
this.hasSubmenu = this.hasSlotController.test('submenu');
|
||||
this.updateHasSubmenuState();
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has('active')) {
|
||||
this.setAttribute('tabindex', this.active ? '0' : '-1');
|
||||
this.customStates.set('active', this.active);
|
||||
}
|
||||
|
||||
if (changedProperties.has('checked')) {
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
this.customStates.set('checked', this.checked);
|
||||
}
|
||||
|
||||
if (changedProperties.has('disabled')) {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
this.customStates.set('disabled', this.disabled);
|
||||
}
|
||||
|
||||
if (changedProperties.has('type')) {
|
||||
if (this.type === 'checkbox') {
|
||||
this.setAttribute('role', 'menuitemcheckbox');
|
||||
} else {
|
||||
this.setAttribute('role', 'menuitem');
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has('submenuOpen')) {
|
||||
this.customStates.set('submenu-open', this.submenuOpen);
|
||||
if (this.submenuOpen) {
|
||||
this.openSubmenu();
|
||||
} else {
|
||||
this.closeSubmenu();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlotChange = () => {
|
||||
this.hasSubmenu = this.hasSlotController.test('submenu');
|
||||
this.updateHasSubmenuState();
|
||||
|
||||
if (this.hasSubmenu) {
|
||||
this.setAttribute('aria-haspopup', 'menu');
|
||||
this.setAttribute('aria-expanded', this.submenuOpen ? 'true' : 'false');
|
||||
} else {
|
||||
this.removeAttribute('aria-haspopup');
|
||||
this.removeAttribute('aria-expanded');
|
||||
}
|
||||
};
|
||||
|
||||
/** Update the has-submenu custom state */
|
||||
private updateHasSubmenuState() {
|
||||
this.customStates.set('has-submenu', this.hasSubmenu);
|
||||
}
|
||||
|
||||
/** Opens the submenu. */
|
||||
async openSubmenu() {
|
||||
if (!this.hasSubmenu || !this.submenuElement) return;
|
||||
|
||||
// Notify parent dropdown to handle positioning
|
||||
this.notifyParentOfOpening();
|
||||
|
||||
// Use Popover API to show the submenu
|
||||
this.submenuElement.showPopover();
|
||||
this.submenuElement.hidden = false;
|
||||
this.submenuElement.setAttribute('data-visible', '');
|
||||
this.submenuOpen = true;
|
||||
this.setAttribute('aria-expanded', 'true');
|
||||
|
||||
// Animate the submenu
|
||||
await animateWithClass(this.submenuElement, 'show');
|
||||
|
||||
// Set focus to the first submenu item
|
||||
setTimeout(() => {
|
||||
const items = this.getSubmenuItems();
|
||||
if (items.length > 0) {
|
||||
items.forEach((item, index) => (item.active = index === 0));
|
||||
items[0].focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Notifies the parent dropdown that this item is opening its submenu */
|
||||
private notifyParentOfOpening() {
|
||||
// First notify the parent that we're about to open
|
||||
const event = new CustomEvent('submenu-opening', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { item: this },
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
|
||||
// Find sibling items that have open submenus and close them
|
||||
const parent = this.parentElement;
|
||||
if (parent) {
|
||||
const siblings = [...parent.children].filter(
|
||||
el =>
|
||||
el !== this &&
|
||||
el.localName === 'wa-dropdown-item' &&
|
||||
el.getAttribute('slot') === this.getAttribute('slot') &&
|
||||
(el as WaDropdownItem).submenuOpen,
|
||||
) as WaDropdownItem[];
|
||||
|
||||
// Close each sibling submenu with animation
|
||||
siblings.forEach(sibling => {
|
||||
sibling.submenuOpen = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes the submenu. */
|
||||
async closeSubmenu() {
|
||||
if (!this.hasSubmenu || !this.submenuElement) return;
|
||||
|
||||
this.submenuOpen = false;
|
||||
this.setAttribute('aria-expanded', 'false');
|
||||
|
||||
if (!this.submenuElement.hidden) {
|
||||
await animateWithClass(this.submenuElement, 'hide');
|
||||
this.submenuElement.hidden = true;
|
||||
this.submenuElement.removeAttribute('data-visible');
|
||||
this.submenuElement.hidePopover();
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets all dropdown items in the submenu. */
|
||||
private getSubmenuItems(): WaDropdownItem[] {
|
||||
// Only get direct children with slot="submenu", not nested ones
|
||||
return [...this.children].filter(
|
||||
el =>
|
||||
el.localName === 'wa-dropdown-item' && el.getAttribute('slot') === 'submenu' && !el.hasAttribute('disabled'),
|
||||
) as WaDropdownItem[];
|
||||
}
|
||||
|
||||
/** Handles mouse enter to open the submenu */
|
||||
private handleMouseEnter() {
|
||||
if (this.hasSubmenu && !this.disabled) {
|
||||
this.notifyParentOfOpening();
|
||||
this.submenuOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
${this.type === 'checkbox'
|
||||
? html`
|
||||
<wa-icon
|
||||
id="check"
|
||||
part="checkmark"
|
||||
exportparts="svg:checkmark__svg"
|
||||
library="system"
|
||||
name="check"
|
||||
></wa-icon>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<span id="icon" part="icon">
|
||||
<slot name="icon"></slot>
|
||||
</span>
|
||||
|
||||
<span id="label" part="label">
|
||||
<slot></slot>
|
||||
</span>
|
||||
|
||||
<span id="details" part="details">
|
||||
<slot name="details"></slot>
|
||||
</span>
|
||||
|
||||
${this.hasSubmenu
|
||||
? html`
|
||||
<wa-icon
|
||||
id="submenu-indicator"
|
||||
part="submenu-icon"
|
||||
exportparts="svg:submenu-icon__svg"
|
||||
library="system"
|
||||
name="chevron-right"
|
||||
></wa-icon>
|
||||
`
|
||||
: ''}
|
||||
${this.hasSubmenu
|
||||
? html`
|
||||
<div
|
||||
id="submenu"
|
||||
part="submenu"
|
||||
popover="manual"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-orientation="vertical"
|
||||
hidden
|
||||
>
|
||||
<slot name="submenu"></slot>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'wa-dropdown-item': WaDropdownItem;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,92 @@
|
||||
:host {
|
||||
--box-shadow: var(--wa-shadow-m);
|
||||
|
||||
display: inline-block;
|
||||
--show-duration: 50ms;
|
||||
--hide-duration: 50ms;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.dropdown::part(popup) {
|
||||
z-index: 900;
|
||||
#menu {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
margin: 0;
|
||||
padding: 0.25em;
|
||||
border: var(--wa-border-style) var(--wa-border-width-s) var(--wa-color-neutral-border-quiet);
|
||||
border-radius: var(--wa-border-radius-m);
|
||||
background-color: var(--wa-color-surface-default);
|
||||
box-shadow: var(--wa-shadow-m);
|
||||
color: var(--wa-color-neutral-on-quiet);
|
||||
text-align: start;
|
||||
user-select: none;
|
||||
|
||||
&.show {
|
||||
animation: show var(--show-duration) ease;
|
||||
}
|
||||
|
||||
&.hide {
|
||||
animation: show var(--hide-duration) ease reverse;
|
||||
}
|
||||
|
||||
::slotted(h1),
|
||||
::slotted(h2),
|
||||
::slotted(h3),
|
||||
::slotted(h4),
|
||||
::slotted(h5),
|
||||
::slotted(h6) {
|
||||
display: block !important;
|
||||
margin: 0.25em 0 !important;
|
||||
padding: 0.25em 1em !important;
|
||||
color: var(--wa-color-text-quiet) !important;
|
||||
font-weight: var(--wa-font-weight-semibold) !important;
|
||||
font-size: 0.75em !important;
|
||||
}
|
||||
|
||||
::slotted(wa-divider) {
|
||||
--spacing: 0.25em; /* Component-specific, left as-is */
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown[data-current-placement^='top']::part(popup) {
|
||||
wa-popup[data-current-placement^='top'] #menu {
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.dropdown[data-current-placement^='bottom']::part(popup) {
|
||||
wa-popup[data-current-placement^='bottom'] #menu {
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.dropdown[data-current-placement^='left']::part(popup) {
|
||||
wa-popup[data-current-placement^='left'] #menu {
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
.dropdown[data-current-placement^='right']::part(popup) {
|
||||
wa-popup[data-current-placement^='right'] #menu {
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
#trigger {
|
||||
display: block; /* for boundingClientRect */
|
||||
wa-popup[data-current-placement='left-start'] #menu {
|
||||
transform-origin: right top;
|
||||
}
|
||||
|
||||
.panel {
|
||||
font: inherit;
|
||||
box-shadow: var(--box-shadow);
|
||||
border-radius: var(--wa-border-radius-m);
|
||||
pointer-events: none;
|
||||
wa-popup[data-current-placement='left-end'] #menu {
|
||||
transform-origin: right bottom;
|
||||
}
|
||||
|
||||
.dropdown-open .panel {
|
||||
display: block;
|
||||
pointer-events: all;
|
||||
wa-popup[data-current-placement='right-start'] #menu {
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
:host([size='small']) ::slotted(wa-menu) {
|
||||
font-size: var(--wa-font-size-s);
|
||||
wa-popup[data-current-placement='right-end'] #menu {
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
|
||||
:host([size='medium']) ::slotted(wa-menu) {
|
||||
font-size: var(--wa-font-size-m);
|
||||
}
|
||||
|
||||
:host([size='large']) ::slotted(wa-menu) {
|
||||
font-size: var(--wa-font-size-l);
|
||||
}
|
||||
|
||||
/* When users slot a menu, make sure it conforms to the popup's auto-size */
|
||||
::slotted(wa-menu) {
|
||||
max-width: var(--auto-size-available-width) !important;
|
||||
max-height: var(--auto-size-available-height) !important;
|
||||
@keyframes show {
|
||||
from {
|
||||
scale: 0.9;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
scale: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,405 +1,9 @@
|
||||
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';
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
|
||||
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"]')!;
|
||||
it('should render a component', async () => {
|
||||
const el = await fixture(html` <wa-dropdown></wa-dropdown> `);
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
expect(el).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import type WaMenuItem from '../components/menu-item/menu-item.js';
|
||||
|
||||
export class WaSelectEvent extends Event {
|
||||
readonly detail;
|
||||
|
||||
@@ -10,7 +8,7 @@ export class WaSelectEvent extends Event {
|
||||
}
|
||||
|
||||
interface WaSelectEventDetail {
|
||||
item: WaMenuItem;
|
||||
item: Element;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -14,6 +14,9 @@ export function animateWithClass(el: Element, className: string) {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
if (el.classList.contains(className)) {
|
||||
return;
|
||||
}
|
||||
el.classList.remove(className);
|
||||
el.classList.add(className);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user