rework dropdown + dropdown item

This commit is contained in:
Cory LaViska
2025-06-06 11:41:23 -04:00
parent c70d2c3778
commit 4ce4c8b8d0
10 changed files with 1554 additions and 8 deletions

View File

@@ -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>

View File

@@ -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.

View File

@@ -0,0 +1,74 @@
---
title: Dropdown
description: Description of component.
layout: component
---
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>
<wa-icon slot="prefix" name="envelope"></wa-icon>
Message
</wa-button>
<h3>Actions</h3>
<wa-dropdown-item value="reply">
<wa-icon slot="icon" name="reply"></wa-icon>
Reply
<kbd slot="details">⌘ R</kbd>
</wa-dropdown-item>
<wa-dropdown-item value="forward">
<wa-icon slot="icon" name="forward"></wa-icon>
Forward
<kbd slot="details">⌘ F</kbd>
</wa-dropdown-item>
<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-divider></wa-divider>
<wa-dropdown-item value="images" type="checkbox" checked> Show images </wa-dropdown-item>
<wa-dropdown-item value="wrap" type="checkbox" checked> Word wrap </wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item>
<wa-icon slot="icon" name="tag"></wa-icon>
Labels
<wa-dropdown-item slot="submenu" value="add-label">
<wa-icon slot="icon" name="plus"></wa-icon>
Add label
</wa-dropdown-item>
<wa-dropdown-item slot="submenu" value="manage-labels">
<wa-icon slot="icon" name="edit"></wa-icon>
Manage labels
</wa-dropdown-item>
</wa-dropdown-item>
<wa-dropdown-item value="preferences">
<wa-icon slot="icon" name="gear"></wa-icon>
Preferences
</wa-dropdown-item>
</wa-dropdown>
```
## Examples
### First Example
TODO
### Second Example
TODO

View File

@@ -0,0 +1,239 @@
: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);
font-size: 0.9375em;
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)) {
cursor: not-allowed;
}
:host([variant='danger']:focus-visible) {
background-color: var(--wa-color-danger-fill-quiet);
color: var(--wa-color-danger-on-quiet);
}
:host(:state(disabled)) {
opacity: 0.5;
}
/* 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;
}
}

View File

@@ -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;
});
});

View File

@@ -0,0 +1,285 @@
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 Short summary of the component's intended use.
* @documentation https://backers.webawesome.com/docs/components/dropdown-item
* @status experimental
* @since 3.0
*
* @dependency wa-example
*
* @slot - TODO - description here
*
*/
@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 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;
}
}

View File

@@ -0,0 +1,91 @@
:host {
--show-duration: 50ms;
display: contents;
}
#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(--show-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 */
}
}
:host([data-placement^='top']) #menu {
transform-origin: bottom;
}
:host([data-placement^='bottom']) #menu {
transform-origin: top;
}
:host([data-placement^='left']) #menu {
transform-origin: right;
}
:host([data-placement^='right']) #menu {
transform-origin: left;
}
:host([data-placement='left-start']) #menu {
transform-origin: right top;
}
:host([data-placement='left-end']) #menu {
transform-origin: right bottom;
}
:host([data-placement='right-start']) #menu {
transform-origin: left top;
}
:host([data-placement='right-end']) #menu {
transform-origin: left bottom;
}
@keyframes show {
from {
scale: 0.9;
opacity: 0;
}
to {
scale: 1;
opacity: 1;
}
}

View File

@@ -0,0 +1,9 @@
import { expect, fixture, html } from '@open-wc/testing';
describe('<wa-dropdown>', () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-dropdown></wa-dropdown> `);
expect(el).to.exist;
});
});

View File

@@ -0,0 +1,816 @@
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import type { PropertyValues } from 'lit';
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { WaAfterHideEvent } from '../../events/after-hide.js';
import { WaAfterShowEvent } from '../../events/after-show.js';
import { WaHideEvent } from '../../events/hide.js';
import { WaSelectEvent } from '../../events/select.js';
import { WaShowEvent } from '../../events/show.js';
import { animateWithClass } from '../../internal/animate.js';
import { uniqueId } from '../../internal/math.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import { LocalizeController } from '../../utilities/localize.js';
import type WaButton from '../button/button.js';
import '../dropdown-item/dropdown-item.js';
import type WaDropdownItem from '../dropdown-item/dropdown-item.js';
import styles from './dropdown.css';
const openDropdowns = new Set<WaDropdown>();
/**
* @summary TODO - short summary of the component's intended use.
* @documentation https://backers.webawesome.com/docs/components/dropdown
* @status stable
* @since 2.0
*
* @dependency wa-dropdown-item
*
* @slot - TODO - description here
*
* @csspart base - The component's base wrapper.
*/
@customElement('wa-dropdown')
export default class WaDropdown extends WebAwesomeElement {
static css = styles;
private cleanup: ReturnType<typeof autoUpdate> | undefined;
private submenuCleanups: Map<WaDropdownItem, ReturnType<typeof autoUpdate>> = new Map();
private readonly localize = new LocalizeController(this);
private userTypedQuery = '';
private userTypedTimeout: ReturnType<typeof setTimeout>;
private openSubmenuStack: WaDropdownItem[] = [];
@query('#menu') private menu: HTMLDivElement;
/** Opens or closes the dropdown. */
@property({ type: Boolean, reflect: true }) open = false;
/**
* The placement of the dropdown menu in reference to the trigger. The menu will shift to a more optimal location if
* the preferred placement doesn't have enough room.
*/
@property({ reflect: true }) placement:
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'right'
| 'right-start'
| 'right-end'
| 'left'
| 'left-start'
| 'left-end' = 'bottom-start';
/** The distance of the dropdown menu from its trigger. */
@property({ type: Number }) distance = 0;
/** The offset of the dropdown menu along its trigger. */
@property({ type: Number }) offset = 0;
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this.userTypedTimeout);
this.closeAllSubmenus();
// Clean up all submenu positioning
this.submenuCleanups.forEach(cleanup => cleanup());
this.submenuCleanups.clear();
document.removeEventListener('mousemove', this.handleGlobalMouseMove);
}
firstUpdated() {
this.syncAriaAttributes();
}
updated(changedProperties: PropertyValues) {
if (changedProperties.has('open')) {
this.customStates.set('open', this.open);
if (this.open) {
this.showMenu();
} else {
this.closeAllSubmenus();
this.hideMenu();
}
}
}
/** Gets all <wa-dropdown-item> elements slotted in the menu that aren't disabled. */
private getItems(includeDisabled = false): WaDropdownItem[] {
// Only select direct children of the dropdown, not deep descendants
const items = [...this.children].filter(
el => el.localName === 'wa-dropdown-item' && !el.hasAttribute('slot'),
) as WaDropdownItem[];
return includeDisabled ? items : items.filter(item => !item.disabled);
}
/** Gets all dropdown items in a specific submenu. */
private getSubmenuItems(parentItem: WaDropdownItem, includeDisabled = false): WaDropdownItem[] {
// Only get direct children with slot="submenu", not nested ones
const items = [...parentItem.children].filter(
el => el.localName === 'wa-dropdown-item' && el.getAttribute('slot') === 'submenu',
) as WaDropdownItem[];
return includeDisabled ? items : items.filter(item => !item.disabled);
}
/** Handles the submenu navigation stack */
private addToSubmenuStack(item: WaDropdownItem) {
// Remove any items that might be after this one in the stack
// This happens if the user navigates back and then to a different submenu
const index = this.openSubmenuStack.indexOf(item);
if (index !== -1) {
this.openSubmenuStack = this.openSubmenuStack.slice(0, index + 1);
} else {
this.openSubmenuStack.push(item);
}
}
/** Removes the last item from the submenu stack */
private removeFromSubmenuStack() {
return this.openSubmenuStack.pop();
}
/** Gets the current active submenu item */
private getCurrentSubmenuItem(): WaDropdownItem | undefined {
return this.openSubmenuStack.length > 0 ? this.openSubmenuStack[this.openSubmenuStack.length - 1] : undefined;
}
/** Closes all submenus in the dropdown. */
private closeAllSubmenus() {
const items = this.getItems(true);
items.forEach(item => {
item.submenuOpen = false;
});
this.openSubmenuStack = [];
}
/** Closes sibling submenus at the same level as the specified item. */
private closeSiblingSubmenus(item: WaDropdownItem) {
// Find direct parent (either another dropdown item or the main dropdown)
const parentDropdownItem = item.closest<WaDropdownItem>('wa-dropdown-item:not([slot="submenu"])');
let siblingItems: WaDropdownItem[];
if (parentDropdownItem) {
// Item is in a submenu, so get sibling items from the parent
siblingItems = this.getSubmenuItems(parentDropdownItem, true);
} else {
// Item is in the top level menu
siblingItems = this.getItems(true);
}
// Close only sibling submenus, not the item itself or its ancestors
siblingItems.forEach(siblingItem => {
if (siblingItem !== item && siblingItem.submenuOpen) {
siblingItem.submenuOpen = false;
}
});
// Don't reset the submenu stack - just add this item if it's not already there
if (!this.openSubmenuStack.includes(item)) {
this.openSubmenuStack.push(item);
}
}
/** Get the slotted trigger button, a <wa-button> or <button> element */
private getTrigger(): HTMLButtonElement | WaButton | null {
return this.querySelector<WaButton | HTMLButtonElement>('[slot="trigger"]');
}
/** Shows the dropdown menu. This should only be called from within updated(). */
private async showMenu() {
const anchor = this.getTrigger();
if (!anchor) return;
const showEvent = new WaShowEvent();
this.dispatchEvent(showEvent);
if (showEvent.defaultPrevented) {
this.open = false;
return;
}
// Close other dropdowns that are open
openDropdowns.forEach(dropdown => (dropdown.open = false));
this.menu.showPopover();
this.open = true;
openDropdowns.add(this);
this.syncAriaAttributes();
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('pointerdown', this.handleDocumentPointerDown);
document.addEventListener('mousemove', this.handleGlobalMouseMove);
this.menu.hidden = false;
this.cleanup = autoUpdate(anchor, this.menu, () => this.reposition());
await animateWithClass(this.menu, 'show');
// Focus the first item after the menu opens
const items = this.getItems();
if (items.length > 0) {
items.forEach((item, index) => (item.active = index === 0));
items[0].focus();
}
this.dispatchEvent(new WaAfterShowEvent());
}
/** Hides the dropdown menu. This should only be called from within updated(). */
private async hideMenu() {
const hideEvent = new WaHideEvent({ source: this });
this.dispatchEvent(hideEvent);
if (hideEvent.defaultPrevented) {
this.open = true;
return;
}
this.open = false;
openDropdowns.delete(this);
this.syncAriaAttributes();
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('pointerdown', this.handleDocumentPointerDown);
document.removeEventListener('mousemove', this.handleGlobalMouseMove);
if (!this.menu.hidden) {
await animateWithClass(this.menu, 'hide');
this.menu.hidden = true;
this.menu.hidePopover();
this.dispatchEvent(new WaAfterHideEvent());
}
if (this.cleanup) {
this.cleanup();
this.cleanup = undefined;
this.removeAttribute('data-placement');
}
}
/** Repositions the dropdown menu */
private reposition() {
const anchor = this.getTrigger();
if (!anchor) return;
computePosition(anchor, this.menu, {
placement: this.placement,
middleware: [offset({ mainAxis: this.distance, crossAxis: this.offset }), flip(), shift()],
}).then(({ x, y, placement }) => {
// Set the determined placement for users to hook into and for transform origin styles
this.setAttribute('data-placement', placement);
// Position it
Object.assign(this.menu.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
/** Handles key down events when the menu is open */
private handleDocumentKeyDown = (event: KeyboardEvent) => {
const isRtl = this.localize.dir() === 'rtl';
// Escape key should close the entire dropdown hierarchy immediately
if (event.key === 'Escape') {
const trigger = this.getTrigger();
event.preventDefault();
event.stopPropagation();
this.open = false;
trigger?.focus();
return;
}
// Get the current active or focused item
const activeElement = document.activeElement as HTMLElement;
const isFocusedOnItem = activeElement?.localName === 'wa-dropdown-item';
// Determine if we're in a submenu
const currentSubmenuItem = this.getCurrentSubmenuItem();
const isInSubmenu = !!currentSubmenuItem;
// Get the appropriate items list based on where we are in the hierarchy
let items: WaDropdownItem[];
let activeItem: WaDropdownItem | undefined;
let activeItemIndex: number;
if (isInSubmenu) {
// We're in a submenu, get items from the current submenu
items = this.getSubmenuItems(currentSubmenuItem);
activeItem = items.find(item => item.active || item === activeElement);
activeItemIndex = activeItem ? items.indexOf(activeItem) : -1;
} else {
// We're in the main menu
items = this.getItems();
activeItem = items.find(item => item.active || item === activeElement);
activeItemIndex = activeItem ? items.indexOf(activeItem) : -1;
}
let itemToSelect: WaDropdownItem | undefined;
// Handle Arrow Up navigation
if (event.key === 'ArrowUp') {
event.preventDefault();
event.stopPropagation();
// If we have an active item, move up, otherwise select the last item
if (activeItemIndex > 0) {
itemToSelect = items[activeItemIndex - 1];
} else {
itemToSelect = items[items.length - 1];
}
}
// Handle Arrow Down navigation
if (event.key === 'ArrowDown') {
event.preventDefault();
event.stopPropagation();
// If we have an active item, move down, otherwise select the first item
if (activeItemIndex !== -1 && activeItemIndex < items.length - 1) {
itemToSelect = items[activeItemIndex + 1];
} else {
itemToSelect = items[0];
}
}
// Handle Arrow Right - open submenu if exists
if (event.key === (isRtl ? 'ArrowLeft' : 'ArrowRight') && isFocusedOnItem && activeItem) {
// Only respond if the active item has a submenu
if (activeItem.hasSubmenu) {
event.preventDefault();
event.stopPropagation();
// Open the submenu
activeItem.submenuOpen = true;
this.addToSubmenuStack(activeItem);
// Focus the first item in the submenu
setTimeout(() => {
const submenuItems = this.getSubmenuItems(activeItem!);
if (submenuItems.length > 0) {
submenuItems.forEach((item, index) => (item.active = index === 0));
submenuItems[0].focus();
}
}, 0);
return;
}
}
// Handle Arrow Left - close current submenu if in a submenu
if (event.key === (isRtl ? 'ArrowRight' : 'ArrowLeft') && isInSubmenu) {
event.preventDefault();
event.stopPropagation();
// Remove the current submenu from the stack
const removedItem = this.removeFromSubmenuStack();
if (removedItem) {
// Close the submenu
removedItem.submenuOpen = false;
// Focus the parent item and restore its active state
setTimeout(() => {
removedItem.focus();
removedItem.active = true;
// Get parent level items to ensure proper keyboard navigation after closing
const parentItems =
removedItem.slot === 'submenu'
? this.getSubmenuItems(removedItem.parentElement as WaDropdownItem)
: this.getItems();
// Reset active state on all items at this level except the current one
parentItems.forEach(item => {
if (item !== removedItem) {
item.active = false;
}
});
}, 0);
}
return;
}
// Home + end for navigation
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
event.stopPropagation();
itemToSelect = event.key === 'Home' ? items[0] : items[items.length - 1];
}
// Tab key
if (event.key === 'Tab') {
this.hideMenu();
}
// Update the selection as the user types
if (
event.key.length === 1 &&
// Ignore special key combinations
!(event.metaKey || event.ctrlKey || event.altKey) &&
// Ignore spaces if the query is empty
!(event.key === ' ' && this.userTypedQuery === '')
) {
// Reset the query after a second of inactivity
clearTimeout(this.userTypedTimeout);
this.userTypedTimeout = setTimeout(() => {
this.userTypedQuery = '';
}, 1000);
this.userTypedQuery += event.key;
// Move selection to the first matching item
items.some(item => {
const label = (item.textContent || '').trim().toLowerCase();
const selectionQuery = this.userTypedQuery.trim().toLowerCase();
if (label.startsWith(selectionQuery)) {
itemToSelect = item;
return true;
}
return false;
});
}
// If a new item will be selected, update the roving tab index and move focus to it
if (itemToSelect) {
event.preventDefault();
event.stopPropagation();
items.forEach(item => (item.active = item === itemToSelect));
itemToSelect.focus();
return;
}
// Handle Enter and Space for selection
if ((event.key === 'Enter' || (event.key === ' ' && this.userTypedQuery === '')) && isFocusedOnItem && activeItem) {
event.preventDefault();
event.stopPropagation();
// Check if this is a submenu item that needs to be opened
if (activeItem.hasSubmenu) {
activeItem.submenuOpen = true;
this.addToSubmenuStack(activeItem);
// Focus the first item in the submenu
setTimeout(() => {
const submenuItems = this.getSubmenuItems(activeItem!);
if (submenuItems.length > 0) {
submenuItems.forEach((item, index) => (item.active = index === 0));
submenuItems[0].focus();
}
}, 0);
} else {
// Regular item - handle selection
this.makeSelection(activeItem);
}
}
};
/** Handles pointer down events when the dropdown is open. */
private handleDocumentPointerDown = (event: PointerEvent) => {
const path = event.composedPath();
// Check if the click is inside any part of the dropdown hierarchy
const isInDropdownHierarchy = path.some(el => {
if (el instanceof HTMLElement) {
// Check if it's part of the dropdown or any of its submenus
return el === this || el.closest('wa-dropdown, [part="submenu"]');
}
return false;
});
if (!isInDropdownHierarchy) {
this.open = false;
}
};
/** Handles clicks on the menu. */
private handleMenuClick(event: MouseEvent) {
const item = (event.target as Element).closest('wa-dropdown-item');
if (!item || item.disabled) return;
// Handle item with submenu - keep it open when clicked
if (item.hasSubmenu) {
// Always open the submenu on click, don't toggle it closed
if (!item.submenuOpen) {
this.closeSiblingSubmenus(item);
this.addToSubmenuStack(item);
item.submenuOpen = true;
}
// Stop propagation to prevent the dropdown from closing
event.stopPropagation();
return;
}
// Handle standard selectable item
this.makeSelection(item);
}
/** Prepares dropdown items when they get added or removed */
private async handleMenuSlotChange() {
const items = this.getItems(true);
await Promise.all(items.map(item => item.updateComplete));
// Check for checkboxes
const hasCheckbox = items.some(item => item.type === 'checkbox');
// Check for submenus
const hasSubmenu = items.some(item => item.hasSubmenu);
// Setup the roving tab index and apply adjacent classes
items.forEach((item, index) => {
item.active = index === 0;
item.checkboxAdjacent = hasCheckbox;
item.submenuAdjacent = hasSubmenu;
});
}
/** Toggles the dropdown menu */
private handleTriggerClick() {
this.open = !this.open;
}
/** Handles submenu opening events */
private handleSubmenuOpening(event: CustomEvent) {
const openingItem = event.detail.item as WaDropdownItem;
this.closeSiblingSubmenus(openingItem);
this.addToSubmenuStack(openingItem);
// Position the submenu
this.setupSubmenuPosition(openingItem);
// Process the submenu items to apply submenuAdjacent
this.processSubmenuItems(openingItem);
}
/** Sets up submenu positioning with autoUpdate */
private setupSubmenuPosition(item: WaDropdownItem) {
if (!item.submenuElement) return;
// Cleanup previous positioning if exists
this.cleanupSubmenuPosition(item);
// Setup new positioning with autoUpdate
const cleanup = autoUpdate(item, item.submenuElement, () => {
this.positionSubmenu(item);
this.updateSafeTriangleCoordinates(item);
});
this.submenuCleanups.set(item, cleanup);
// Add a slotchange listener to handle submenu items
const submenuSlot = item.submenuElement.querySelector('slot[name="submenu"]');
if (submenuSlot) {
// Remove any existing listener to prevent duplicates
submenuSlot.removeEventListener('slotchange', WaDropdown.handleSubmenuSlotChange);
// Add the listener
submenuSlot.addEventListener('slotchange', WaDropdown.handleSubmenuSlotChange);
// Process initially assigned items
WaDropdown.handleSubmenuSlotChange({ target: submenuSlot } as unknown as Event);
}
}
private static handleSubmenuSlotChange(event: Event) {
const slot = event.target as HTMLSlotElement;
if (!slot) return;
// Get all assigned elements to this slot
const items = slot.assignedElements().filter(el => el.localName === 'wa-dropdown-item') as WaDropdownItem[];
if (items.length === 0) return;
// Check if any item has a submenu
const hasSubmenuItems = items.some(item => item.hasSubmenu);
// Check if any item is a checkbox
const hasCheckboxItems = items.some(item => item.type === 'checkbox');
// Apply submenu-adjacent and checkbox-adjacent to all items if needed
items.forEach(item => {
item.submenuAdjacent = hasSubmenuItems;
item.checkboxAdjacent = hasCheckboxItems;
});
}
private processSubmenuItems(item: WaDropdownItem) {
if (!item.submenuElement) return;
// Get all dropdown items in the submenu
const submenuItems = this.getSubmenuItems(item, true);
// Check if any item has a submenu
const hasSubmenuItems = submenuItems.some(subItem => subItem.hasSubmenu);
// Apply submenu-adjacent to all items if needed
submenuItems.forEach(subItem => {
subItem.submenuAdjacent = hasSubmenuItems;
});
}
/** Cleans up submenu positioning */
private cleanupSubmenuPosition(item: WaDropdownItem) {
const cleanup = this.submenuCleanups.get(item);
if (cleanup) {
cleanup();
this.submenuCleanups.delete(item);
}
}
/** Positions a submenu relative to its parent item */
private positionSubmenu(item: WaDropdownItem) {
if (!item.submenuElement) return;
// Determine placement based on text direction
const isRtl = this.localize.dir() === 'rtl';
const placement = isRtl ? 'left-start' : 'right-start';
computePosition(item, item.submenuElement, {
placement: placement,
middleware: [
offset({
mainAxis: 0,
crossAxis: -5,
}),
flip({
fallbackStrategy: 'bestFit',
}),
shift({
padding: 8,
}),
],
}).then(({ x, y, placement }) => {
// Set placement for transform origin styles
item.submenuElement.setAttribute('data-placement', placement);
// Position it
Object.assign(item.submenuElement.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
/** Updates the safe triangle coordinates for a submenu */
private updateSafeTriangleCoordinates(item: WaDropdownItem) {
if (!item.submenuElement || !item.submenuOpen) return;
// Detect if we're in keyboard navigation mode by checking focus-visible
const isKeyboardNavigation = document.activeElement?.matches(':focus-visible');
// If using keyboard navigation, don't show the safe triangle
if (isKeyboardNavigation) {
// Hide the safe triangle for keyboard navigation
item.submenuElement.style.setProperty('--safe-triangle-visible', 'none');
return;
}
// Enable the safe triangle for mouse navigation
item.submenuElement.style.setProperty('--safe-triangle-visible', 'block');
const submenuRect = item.submenuElement.getBoundingClientRect();
const isRtl = this.localize.dir() === 'rtl';
// Set the start and end points of the submenu side of the triangle
// In RTL, we use the right edge of the submenu; in LTR, we use the left edge
item.submenuElement.style.setProperty(
'--safe-triangle-submenu-start-x',
`${isRtl ? submenuRect.right : submenuRect.left}px`,
);
item.submenuElement.style.setProperty('--safe-triangle-submenu-start-y', `${submenuRect.top}px`);
item.submenuElement.style.setProperty(
'--safe-triangle-submenu-end-x',
`${isRtl ? submenuRect.right : submenuRect.left}px`,
);
item.submenuElement.style.setProperty('--safe-triangle-submenu-end-y', `${submenuRect.bottom}px`);
}
/** Handle global mouse movement for safe triangle logic */
private handleGlobalMouseMove = (event: MouseEvent) => {
// Find the last open submenu item
const currentSubmenuItem = this.getCurrentSubmenuItem();
if (!currentSubmenuItem?.submenuOpen || !currentSubmenuItem.submenuElement) return;
// Get submenu rect for boundary checking
const submenuRect = currentSubmenuItem.submenuElement.getBoundingClientRect();
const isRtl = this.localize.dir() === 'rtl';
// Determine the submenu edge x-coordinate
// LTR: we use the left edge.
// RTL: use the right edge
const submenuEdgeX = isRtl ? submenuRect.right : submenuRect.left;
// Calculate the constrained cursor position
// LTR: cursor must be to the left of submenu edge (min)
// RTL: cursor must be to the right of submenu edge (max)
const constrainedX = isRtl ? Math.max(event.clientX, submenuEdgeX) : Math.min(event.clientX, submenuEdgeX);
const constrainedY = Math.max(submenuRect.top, Math.min(event.clientY, submenuRect.bottom));
// Update cursor position
currentSubmenuItem.submenuElement.style.setProperty('--safe-triangle-cursor-x', `${constrainedX}px`);
currentSubmenuItem.submenuElement.style.setProperty('--safe-triangle-cursor-y', `${constrainedY}px`);
// Check if mouse is in safe area
const isOverItem = currentSubmenuItem.matches(':hover');
const isOverSubmenu =
currentSubmenuItem.submenuElement?.matches(':hover') ||
!!event
.composedPath()
.find(el => el instanceof HTMLElement && el.closest('[part="submenu"]') === currentSubmenuItem.submenuElement);
// Close if not in safe area
if (!isOverItem && !isOverSubmenu) {
setTimeout(() => {
if (!currentSubmenuItem.matches(':hover') && !currentSubmenuItem.submenuElement?.matches(':hover')) {
currentSubmenuItem.submenuOpen = false;
}
}, 100);
}
};
/** Makes a selection, emits the wa-select event, and closes the dropdown. */
private makeSelection(item: WaDropdownItem) {
const trigger = this.getTrigger();
// Disabled items can't be selected
if (item.disabled) {
return;
}
// Toggle checkbox items
if (item.type === 'checkbox') {
item.checked = !item.checked;
}
const selectEvent = new WaSelectEvent({ item });
this.dispatchEvent(selectEvent);
// If the event was canceled, keep the dropdown open
if (!selectEvent.defaultPrevented) {
this.open = false;
trigger?.focus();
}
}
/** Syncs aria attributes on the slotted trigger element and the menu based on the dropdown's current state */
private async syncAriaAttributes() {
// Set aria attributes on the trigger
const trigger = this.getTrigger();
let nativeButton: HTMLButtonElement | undefined;
if (!trigger) {
return;
}
if (trigger.localName === 'wa-button') {
await customElements.whenDefined('wa-button');
await (trigger as WaButton).updateComplete;
nativeButton = trigger.shadowRoot!.querySelector<HTMLButtonElement>('[part="button"]')!;
} else {
nativeButton = trigger as HTMLButtonElement;
}
// Set an ID on the trigger if one doesn't already exist
if (!nativeButton.hasAttribute('id')) {
nativeButton.setAttribute('id', uniqueId('wa-dropdown-trigger-'));
}
nativeButton.setAttribute('aria-haspopup', 'menu');
nativeButton.setAttribute('aria-expanded', this.open ? 'true' : 'false');
this.menu.setAttribute('aria-expanded', 'false');
}
render() {
return html`
<slot name="trigger" @click=${this.handleTriggerClick} @slotchange=${this.syncAriaAttributes}></slot>
<div
id="menu"
part="menu"
popover="manual"
role="menu"
tabindex="-1"
aria-orientation="vertical"
hidden
@click=${this.handleMenuClick}
@submenu-opening=${this.handleSubmenuOpening}
>
<slot @slotchange=${this.handleMenuSlotChange}></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-dropdown': WaDropdown;
}
}

View File

@@ -0,0 +1,18 @@
export class WaSelectEvent extends Event {
readonly detail;
constructor(detail: WaSelectEventDetail) {
super('wa-select', { bubbles: true, cancelable: false, composed: true });
this.detail = detail;
}
}
interface WaSelectEventDetail {
item: Element;
}
declare global {
interface GlobalEventHandlersEventMap {
'wa-select': WaSelectEvent;
}
}