mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 04:09:12 +00:00
* [RFC] Proof-of-concept commit for submenu support
This is a Request For Comments to seek directional guidance towards
implementing the submenu slot of menu-item.
Includes:
- SubmenuController to manage event listeners on menu-item.
- Example usage in menu-item documentation.
- Trivial tests to check rendering.
Outstanding questions include:
- Accessibility concerns. E.g. where to handle 'ArrowRight',
'ArrowLeft'?
- Should selection of menu-item denoting submenu be possible or
customizable?
- How to parameterize contained popup?
- Implementation concerns:
- Use of ref / id
- delegation of some rendering to the controller
- What to test
Related to [#620](https://github.com/shoelace-style/shoelace/issues/620).
* Update submenu-controller.ts
Removed extraneous `console.log()`.
* PoC working of ArrowRight to focus on submenu.
* Revert "PoC working of ArrowRight to focus on submenu."
(Didn't mean to publish this.)
This reverts commit be04e9a221.
* [WIP] Submenu WIP continues.
- Submenus now close on change-of-focus, not a timeout.
- Keyboard navigation support added.
- Skidding fix for better alignment.
- Submenu documentation moved to Menu page.
- Tests for accessibility, right and left arrow keys.
* Cleanup: Removed dead code and dead code comments.
* style: Eslint warnings and errors fixed. npm run verify now passes.
* fix: 2 changes to menu / submenu on-click behavior:
1. Close submenu on click explicitly, so this occurs even if the menu is
not inside of an sl-dropdown.
2. In menu, ignore clicks that do not explicitly target a menu-item.
Clicks that were on (e.g. a menu-border) were emitting select events.
* fix: Prevent menu's extraneous Enter / space key propagation.
Menu's handleKeyDown calls item.click (to emit the selection).
Propagating the keyboard event on Enter / space would the cause re-entry
into a submenu, so prevent the needless propagation.
* Submenu tweaks ...
- 100 ms delay when opening submenus on mouseover
- Shadows added
- Distance added to popup to have submenus overlap menu slightly.
* polish up submenu stuff
* stay highlighted when submenu is open
* update changelog
* resolve feedback
---------
Co-authored-by: Bryce Moore <bryce.moore@gmail.com>
186 lines
6.4 KiB
TypeScript
186 lines
6.4 KiB
TypeScript
import { classMap } from 'lit/directives/class-map.js';
|
|
import { getTextContent, HasSlotController } from '../../internal/slot.js';
|
|
import { html } from 'lit';
|
|
import { LocalizeController } from '../../utilities/localize.js';
|
|
import { property, query } from 'lit/decorators.js';
|
|
import { SubmenuController } from './submenu-controller.js';
|
|
import { watch } from '../../internal/watch.js';
|
|
import ShoelaceElement from '../../internal/shoelace-element.js';
|
|
import SlIcon from '../icon/icon.component.js';
|
|
import SlPopup from '../popup/popup.component.js';
|
|
import styles from './menu-item.styles.js';
|
|
import type { CSSResultGroup } from 'lit';
|
|
|
|
/**
|
|
* @summary Menu items provide options for the user to pick from in a menu.
|
|
* @documentation https://shoelace.style/components/menu-item
|
|
* @status stable
|
|
* @since 2.0
|
|
*
|
|
* @dependency sl-icon
|
|
* @dependency sl-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.
|
|
*
|
|
* @csspart base - The component's base wrapper.
|
|
* @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 submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
|
|
*
|
|
* @cssproperty [--submenu-offset=-2px] - The distance submenus shift to overlap the parent menu.
|
|
*/
|
|
export default class SlMenuItem extends ShoelaceElement {
|
|
static styles: CSSResultGroup = styles;
|
|
static dependencies = {
|
|
'sl-icon': SlIcon,
|
|
'sl-popup': SlPopup
|
|
};
|
|
|
|
private cachedTextLabel: string;
|
|
|
|
@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 disabled state, preventing selection. */
|
|
@property({ type: Boolean, reflect: true }) disabled = false;
|
|
|
|
private readonly localize = new LocalizeController(this);
|
|
private readonly hasSlotController = new HasSlotController(this, 'submenu');
|
|
private submenuController: SubmenuController = new SubmenuController(this, this.hasSlotController, this.localize);
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this.addEventListener('click', this.handleHostClick);
|
|
this.addEventListener('mouseover', this.handleMouseOver);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.removeEventListener('click', this.handleHostClick);
|
|
this.removeEventListener('mouseover', this.handleMouseOver);
|
|
}
|
|
|
|
private handleDefaultSlotChange() {
|
|
const textLabel = this.getTextLabel();
|
|
|
|
// Ignore the first time the label is set
|
|
if (typeof this.cachedTextLabel === 'undefined') {
|
|
this.cachedTextLabel = textLabel;
|
|
return;
|
|
}
|
|
|
|
// When the label changes, emit a slotchange event so parent controls see it
|
|
if (textLabel !== this.cachedTextLabel) {
|
|
this.cachedTextLabel = textLabel;
|
|
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
|
|
}
|
|
}
|
|
|
|
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();
|
|
};
|
|
|
|
@watch('checked')
|
|
handleCheckedChange() {
|
|
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
|
|
if (this.checked && this.type !== 'checkbox') {
|
|
this.checked = false;
|
|
console.error('The checked attribute can only be used on menu items with type="checkbox"', this);
|
|
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');
|
|
}
|
|
}
|
|
|
|
@watch('disabled')
|
|
handleDisabledChange() {
|
|
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
|
}
|
|
|
|
@watch('type')
|
|
handleTypeChange() {
|
|
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');
|
|
}
|
|
}
|
|
|
|
/** Returns a text label based on the contents of the menu item's default slot. */
|
|
getTextLabel() {
|
|
return getTextContent(this.defaultSlot);
|
|
}
|
|
|
|
isSubmenu() {
|
|
return this.hasSlotController.test('submenu');
|
|
}
|
|
|
|
render() {
|
|
const isRtl = this.localize.dir() === 'rtl';
|
|
const isSubmenuExpanded = this.submenuController.isExpanded();
|
|
|
|
return html`
|
|
<div
|
|
id="anchor"
|
|
part="base"
|
|
class=${classMap({
|
|
'menu-item': true,
|
|
'menu-item--rtl': isRtl,
|
|
'menu-item--checked': this.checked,
|
|
'menu-item--disabled': this.disabled,
|
|
'menu-item--has-submenu': this.isSubmenu(),
|
|
'menu-item--submenu-expanded': isSubmenuExpanded
|
|
})}
|
|
?aria-haspopup="${this.isSubmenu()}"
|
|
?aria-expanded="${isSubmenuExpanded ? true : false}"
|
|
>
|
|
<span part="checked-icon" class="menu-item__check">
|
|
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
|
|
</span>
|
|
|
|
<slot name="prefix" part="prefix" class="menu-item__prefix"></slot>
|
|
|
|
<slot part="label" class="menu-item__label" @slotchange=${this.handleDefaultSlotChange}></slot>
|
|
|
|
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
|
|
|
|
<span part="submenu-icon" class="menu-item__chevron">
|
|
<sl-icon name=${isRtl ? 'chevron-left' : 'chevron-right'} library="system" aria-hidden="true"></sl-icon>
|
|
</span>
|
|
|
|
${this.submenuController.renderSubmenu()}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|