diff --git a/packages/webawesome/docs/_includes/sidebar.njk b/packages/webawesome/docs/_includes/sidebar.njk index 51473ac72..ad06871e1 100644 --- a/packages/webawesome/docs/_includes/sidebar.njk +++ b/packages/webawesome/docs/_includes/sidebar.njk @@ -121,20 +121,18 @@
  • Dialog
  • Divider
  • Drawer
  • -
  • Dropdown
  • +
  • + Dropdown + +
  • Format Bytes
  • Format Date
  • Format Number
  • Icon
  • Include
  • Input
  • -
  • - Menu - -
  • Mutation Observer
  • Popover
  • Popup
  • diff --git a/packages/webawesome/docs/docs/components/dropdown-item.md b/packages/webawesome/docs/docs/components/dropdown-item.md new file mode 100644 index 000000000..861b87f0a --- /dev/null +++ b/packages/webawesome/docs/docs/components/dropdown-item.md @@ -0,0 +1,7 @@ +--- +title: Dropdown Item +description: Description of component. +layout: component +--- + +This component must be used as a child of ``. Please see the [Dropdown docs](/docs/components/dropdown) to see examples of this component in action. diff --git a/packages/webawesome/docs/docs/components/dropdown.md b/packages/webawesome/docs/docs/components/dropdown.md new file mode 100644 index 000000000..312074fa0 --- /dev/null +++ b/packages/webawesome/docs/docs/components/dropdown.md @@ -0,0 +1,74 @@ +--- +title: Dropdown +description: Description of component. +layout: component +--- + +```html {.example} + + + + Message + + +

    Actions

    + + + + Reply + ⌘ R + + + + + Forward + ⌘ F + + + + + Archive + + + + + Delete + + + + + Show images + + Word wrap + + + + + + Labels + + + Add label + + + + Manage labels + + + + + + Preferences + +
    +``` + +## Examples + +### First Example + +TODO + +### Second Example + +TODO diff --git a/packages/webawesome/src/components/dropdown-item/dropdown-item.css b/packages/webawesome/src/components/dropdown-item/dropdown-item.css new file mode 100644 index 000000000..3cfac4e0d --- /dev/null +++ b/packages/webawesome/src/components/dropdown-item/dropdown-item.css @@ -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; + } +} diff --git a/packages/webawesome/src/components/dropdown-item/dropdown-item.test.ts b/packages/webawesome/src/components/dropdown-item/dropdown-item.test.ts new file mode 100644 index 000000000..3fa2c28c1 --- /dev/null +++ b/packages/webawesome/src/components/dropdown-item/dropdown-item.test.ts @@ -0,0 +1,9 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/packages/webawesome/src/components/dropdown-item/dropdown-item.ts b/packages/webawesome/src/components/dropdown-item/dropdown-item.ts new file mode 100644 index 000000000..cf9491e68 --- /dev/null +++ b/packages/webawesome/src/components/dropdown-item/dropdown-item.ts @@ -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) { + 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` + + ` + : ''} + + + + + + + + + + + + + + ${this.hasSubmenu + ? html` + + ` + : ''} + ${this.hasSubmenu + ? html` + + ` + : ''} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'wa-dropdown-item': WaDropdownItem; + } +} diff --git a/packages/webawesome/src/components/dropdown/dropdown.css b/packages/webawesome/src/components/dropdown/dropdown.css new file mode 100644 index 000000000..100ef6433 --- /dev/null +++ b/packages/webawesome/src/components/dropdown/dropdown.css @@ -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; + } +} diff --git a/packages/webawesome/src/components/dropdown/dropdown.test.ts b/packages/webawesome/src/components/dropdown/dropdown.test.ts new file mode 100644 index 000000000..0ec43f66a --- /dev/null +++ b/packages/webawesome/src/components/dropdown/dropdown.test.ts @@ -0,0 +1,9 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/packages/webawesome/src/components/dropdown/dropdown.ts b/packages/webawesome/src/components/dropdown/dropdown.ts new file mode 100644 index 000000000..d617ad785 --- /dev/null +++ b/packages/webawesome/src/components/dropdown/dropdown.ts @@ -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(); + +/** + * @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 | undefined; + private submenuCleanups: Map> = new Map(); + private readonly localize = new LocalizeController(this); + private userTypedQuery = ''; + private userTypedTimeout: ReturnType; + 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 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('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 or