remove old dropdown

This commit is contained in:
Cory LaViska
2025-06-06 10:16:49 -04:00
parent c1441abe15
commit c70d2c3778
3 changed files with 0 additions and 672 deletions

View File

@@ -1,182 +0,0 @@
---
title: Dropdown
description: 'Dropdowns expose additional content that "drops down" in a panel.'
tags: [actions, apps]
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.
```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>
```
## Examples
### 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.
```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-dropdown>
</div>
<script>
const container = document.querySelector('.dropdown-selection');
const dropdown = container.querySelector('wa-dropdown');
dropdown.addEventListener('wa-select', event => {
const selectedItem = event.detail.item;
console.log(selectedItem.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.
```html {.example}
<div class="dropdown-selection-alt">
<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>
</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"]');
cut.addEventListener('click', () => console.log('cut'));
copy.addEventListener('click', () => console.log('copy'));
paste.addEventListener('click', () => console.log('paste'));
</script>
```
### 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>
```
### Distance
The distance from the panel to the trigger can be customized using the `distance` attribute. This value is specified in pixels.
```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>
```
### Skidding
The offset of the panel along the trigger can be customized using the `skidding` attribute. This value is specified in pixels.
```html {.example}
<wa-dropdown skidding="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>
```
### Submenus
To create a submenu, nest an `<wa-menu slot="submenu">` element in a [menu item](/docs/components/menu-item).
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>Edit</wa-button>
<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>
```
:::warning
As a UX best practice, avoid using more than one level of submenu when possible.
:::

View File

@@ -1,60 +0,0 @@
:host {
--box-shadow: var(--wa-shadow-m);
display: inline-block;
}
.dropdown::part(popup) {
z-index: 900;
}
.dropdown[data-current-placement^='top']::part(popup) {
transform-origin: bottom;
}
.dropdown[data-current-placement^='bottom']::part(popup) {
transform-origin: top;
}
.dropdown[data-current-placement^='left']::part(popup) {
transform-origin: right;
}
.dropdown[data-current-placement^='right']::part(popup) {
transform-origin: left;
}
#trigger {
display: block; /* for boundingClientRect */
}
.panel {
font: inherit;
box-shadow: var(--box-shadow);
border-radius: var(--wa-border-radius-m);
pointer-events: none;
}
.dropdown-open .panel {
display: block;
pointer-events: all;
}
/* Sizes */
:host([size='small']) ::slotted(wa-menu) {
font-size: var(--wa-font-size-s);
}
: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;
}

View File

@@ -1,430 +0,0 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { WaAfterHideEvent } from '../../events/after-hide.js';
import { WaAfterShowEvent } from '../../events/after-show.js';
import { WaHideEvent } from '../../events/hide.js';
import { WaShowEvent } from '../../events/show.js';
import { animateWithClass } from '../../internal/animate.js';
import { waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import sizeStyles from '../../styles/utilities/size.css';
import type WaButton from '../button/button.js';
import '../popup/popup.js';
import type WaPopup from '../popup/popup.js';
import styles from './dropdown.css';
/**
* @summary Dropdowns expose additional content that "drops down" in a panel.
* @documentation https://backers.webawesome.com/docs/components/dropdown
* @status stable
* @since 2.0
*
* @dependency wa-popup
*
* @slot - The dropdown's main content.
* @slot trigger - The dropdown's trigger, usually a `<wa-button>` element.
*
* @event wa-show - Emitted when the dropdown opens.
* @event wa-after-show - Emitted after the dropdown opens and all animations are complete.
* @event wa-hide - Emitted when the dropdown closes.
* @event wa-after-hide - Emitted after the dropdown closes and all animations are complete.
*
* @cssproperty --box-shadow - The shadow effects around the dropdown's edges.
*
* @csspart base - The component's base wrapper, a `<wa-popup>` element.
* @csspart base__popup - The popup's exported `popup` part. Use this to target the tooltip's popup container.
* @csspart trigger - The container that wraps the trigger.
* @csspart panel - The panel that gets shown when the dropdown is open.
*/
@customElement('wa-dropdown')
export default class WaDropdown extends WebAwesomeElement {
static css = [sizeStyles, styles];
@query('.dropdown') popup: WaPopup;
@query('#trigger') trigger: HTMLSlotElement;
@query('.panel') panel: HTMLSlotElement;
/**
* Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
* can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
* inside of the viewport.
*/
@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';
/** Disables the dropdown so the panel will not open. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for
* dropdowns that allow for multiple interactions.
*/
@property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;
/**
* The dropdown will close when the user interacts outside of this element (e.g. clicking). Useful for composing other
* components that use a dropdown internally.
*/
@property({ attribute: false }) containingElement?: HTMLElement;
/** The distance in pixels from which to offset the panel away from its trigger. */
@property({ type: Number }) distance = 0;
/** The distance in pixels from which to offset the panel along its trigger. */
@property({ type: Number }) skidding = 0;
/**
* Syncs the popup width or height to that of the trigger element.
*/
@property({ reflect: true }) sync: 'width' | 'height' | 'both' | undefined = undefined;
connectedCallback() {
super.connectedCallback();
if (!this.containingElement) {
this.containingElement = this;
}
}
firstUpdated() {
this.panel.hidden = !this.open;
// If the dropdown is visible on init, update its position
if (this.open) {
this.addOpenListeners();
this.popup.active = true;
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeOpenListeners();
this.hide();
}
focusOnTrigger() {
const trigger = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
if (typeof trigger?.focus === 'function') {
trigger.focus();
}
}
getMenu() {
return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'wa-menu') as
| any
| undefined;
}
private handleKeyDown = (event: KeyboardEvent) => {
// Close when escape is pressed inside an open dropdown. 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.focusOnTrigger();
}
};
private handleDocumentKeyDown = (event: KeyboardEvent) => {
// Close when escape or tab is pressed
if (event.key === 'Escape' && this.open) {
event.stopPropagation();
this.focusOnTrigger();
this.hide();
return;
}
// Handle tabbing
if (event.key === 'Tab') {
// Tabbing within an open menu should close the dropdown and refocus the trigger
if (this.open && document.activeElement?.tagName.toLowerCase() === 'wa-menu-item') {
event.preventDefault();
this.hide();
this.focusOnTrigger();
return;
}
// Tabbing outside of the containing element closes the panel
//
// If the dropdown 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.containingElement?.getRootNode() instanceof ShadowRoot
? document.activeElement?.shadowRoot?.activeElement
: document.activeElement;
if (
!this.containingElement ||
activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
) {
this.hide();
}
});
}
};
private handleDocumentMouseDown = (event: MouseEvent) => {
// Close when clicking outside of the containing element
const path = event.composedPath();
if (this.containingElement && !path.includes(this.containingElement)) {
this.hide();
}
};
private handlePanelSelect = (event: CustomEvent) => {
const target = event.target as HTMLElement;
// Hide the dropdown when a menu item is selected
if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'wa-menu') {
this.hide();
this.focusOnTrigger();
}
};
handleTriggerClick() {
if (this.open) {
this.hide();
} else {
this.show();
this.focusOnTrigger();
}
}
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;
}
const menu = this.getMenu();
if (menu) {
const menuItems = menu.getAllItems();
const firstMenuItem = menuItems[0];
const lastMenuItem = menuItems[menuItems.length - 1];
// When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a
// selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for
// faster navigation.
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
// Show the menu if it's not already open
if (!this.open) {
this.show();
// Wait for the dropdown to open before focusing, but not the animation
await this.updateComplete;
}
if (menuItems.length > 0) {
// Focus on the first/last menu item after showing
this.updateComplete.then(() => {
if (event.key === 'ArrowDown' || event.key === 'Home') {
menu.setCurrentItem(firstMenuItem);
firstMenuItem.focus();
}
if (event.key === 'ArrowUp' || event.key === 'End') {
menu.setCurrentItem(lastMenuItem);
lastMenuItem.focus();
}
});
}
}
}
}
handleTriggerKeyUp(event: KeyboardEvent) {
// Prevent space from triggering a click event in Firefox
if (event.key === ' ') {
event.preventDefault();
}
}
handleTriggerSlotChange() {
this.updateAccessibleTrigger();
}
updateAccessibleTrigger() {
const assignedElements = this.trigger.assignedElements({ flatten: true }) as HTMLElement[];
const accessibleTrigger = assignedElements[0];
let target: HTMLElement;
if (accessibleTrigger) {
const tagName = accessibleTrigger.tagName.toLowerCase();
switch (tagName) {
// Web Awesome buttons have to update the internal button so it's announced correctly by screen readers
case 'wa-button':
target = (accessibleTrigger as WaButton).button;
// Either the tag hasn't registered, or it hasn't rendered.
// So, wait for the tag to register, and then try again.
if (target === undefined || target === null) {
customElements.whenDefined(tagName).then(async () => {
await (accessibleTrigger as WaButton).updateComplete;
this.updateAccessibleTrigger();
});
return;
}
break;
default:
target = accessibleTrigger;
}
target.setAttribute('aria-haspopup', 'true');
target.setAttribute('aria-expanded', this.open ? 'true' : 'false');
}
}
/** Shows the dropdown panel. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'wa-after-show');
}
/** Hides the dropdown panel */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'wa-after-hide');
}
/**
* Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
* is activated.
*/
reposition() {
this.popup.reposition();
}
addOpenListeners() {
this.panel.addEventListener('wa-select', this.handlePanelSelect);
this.panel.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
removeOpenListeners() {
if (this.panel) {
this.panel.removeEventListener('wa-select', this.handlePanelSelect);
this.panel.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
const waShowEvent = new WaShowEvent();
this.dispatchEvent(waShowEvent);
if (waShowEvent.defaultPrevented) {
this.open = false;
return;
}
this.addOpenListeners();
this.panel.hidden = false;
this.popup.active = true;
await animateWithClass(this.popup.popup, 'show-with-scale');
this.dispatchEvent(new WaAfterShowEvent());
} else {
// Hide
const waHideEvent = new WaHideEvent();
this.dispatchEvent(waHideEvent);
if (waHideEvent.defaultPrevented) {
this.open = true;
return;
}
this.removeOpenListeners();
await animateWithClass(this.popup.popup, 'hide-with-scale');
this.panel.hidden = true;
this.popup.active = false;
this.dispatchEvent(new WaAfterHideEvent());
}
}
render() {
return html`
<slot
name="trigger"
id="trigger"
part="trigger"
@click=${this.handleTriggerClick}
@keydown=${this.handleTriggerKeyDown}
@keyup=${this.handleTriggerKeyUp}
@slotchange=${this.handleTriggerSlotChange}
></slot>
<wa-popup
part="base"
exportparts="popup:base__popup"
id="dropdown"
anchor="trigger"
placement=${this.placement}
distance=${this.distance}
skidding=${this.skidding}
flip
shift
auto-size="vertical"
auto-size-padding="10"
sync=${ifDefined(this.sync ? this.sync : undefined)}
class=${classMap({
dropdown: true,
'dropdown-open': this.open,
})}
>
<div aria-hidden=${this.open ? 'false' : 'true'} aria-labelledby="dropdown">
<slot part="panel" class="panel"></slot>
</div>
</wa-popup>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-dropdown': WaDropdown;
}
}