kopia lustrzana https://github.com/shoelace-style/shoelace
Submenus (#1527)
* [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>
pull/1533/head
rodzic
539eaded73
commit
a4fc1c5b44
|
@ -310,6 +310,96 @@ const App = () => (
|
|||
);
|
||||
```
|
||||
|
||||
### Submenus
|
||||
|
||||
To create a submenu, nest an `<sl-menu slot="submenu">` element in a [menu item](/components/menu-item).
|
||||
|
||||
```html:preview
|
||||
<sl-dropdown>
|
||||
<sl-button slot="trigger" caret>Edit</sl-button>
|
||||
|
||||
<sl-menu style="max-width: 200px;">
|
||||
<sl-menu-item value="undo">Undo</sl-menu-item>
|
||||
<sl-menu-item value="redo">Redo</sl-menu-item>
|
||||
<sl-divider></sl-divider>
|
||||
<sl-menu-item value="cut">Cut</sl-menu-item>
|
||||
<sl-menu-item value="copy">Copy</sl-menu-item>
|
||||
<sl-menu-item value="paste">Paste</sl-menu-item>
|
||||
<sl-divider></sl-divider>
|
||||
<sl-menu-item>
|
||||
Find
|
||||
<sl-menu slot="submenu">
|
||||
<sl-menu-item value="find">Find…</sl-menu-item>
|
||||
<sl-menu-item value="find-previous">Find Next</sl-menu-item>
|
||||
<sl-menu-item value="find-next">Find Previous</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-menu-item>
|
||||
<sl-menu-item>
|
||||
Transformations
|
||||
<sl-menu slot="submenu">
|
||||
<sl-menu-item value="uppercase">Make uppercase</sl-menu-item>
|
||||
<sl-menu-item value="lowercase">Make lowercase</sl-menu-item>
|
||||
<sl-menu-item value="capitalize">Capitalize</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-dropdown>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import SlButton from '@shoelace-style/shoelace/dist/react/button';
|
||||
import SlDivider from '@shoelace-style/shoelace/dist/react/divider';
|
||||
import SlDropdown from '@shoelace-style/shoelace/dist/react/dropdown';
|
||||
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
|
||||
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
|
||||
|
||||
const css = `
|
||||
.dropdown-hoist {
|
||||
border: solid 2px var(--sl-panel-border-color);
|
||||
padding: var(--sl-spacing-medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<SlDropdown>
|
||||
<SlButton slot="trigger" caret>Edit</SlButton>
|
||||
|
||||
<SlMenu style="max-width: 200px;">
|
||||
<SlMenuItem value="undo">Undo</SlMenuItem>
|
||||
<SlMenuItem value="redo">Redo</SlMenuItem>
|
||||
<SlDivider />
|
||||
<SlMenuItem value="cut">Cut</SlMenuItem>
|
||||
<SlMenuItem value="copy">Copy</SlMenuItem>
|
||||
<SlMenuItem value="paste">Paste</SlMenuItem>
|
||||
<SlDivider />
|
||||
<SlMenuItem>
|
||||
Find
|
||||
<SlMenu slot="submenu">
|
||||
<SlMenuItem value="find">Find…</SlMenuItem>
|
||||
<SlMenuItem value="find-previous">Find Next</SlMenuItem>
|
||||
<SlMenuItem value="find-next">Find Previous</SlMenuItem>
|
||||
</SlMenu>
|
||||
</SlMenuItem>
|
||||
<SlMenuItem>
|
||||
Transformations
|
||||
<SlMenu slot="submenu">
|
||||
<SlMenuItem value="uppercase">Make uppercase</SlMenuItem>
|
||||
<SlMenuItem value="lowercase">Make lowercase</SlMenuItem>
|
||||
<SlMenuItem value="capitalize">Capitalize</SlMenuItem>
|
||||
</SlMenu>
|
||||
</SlMenuItem>
|
||||
</SlMenu>
|
||||
</SlDropdown>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
:::warning
|
||||
As a UX best practice, avoid using more than one level of submenu when possible.
|
||||
:::
|
||||
|
||||
### Hoisting
|
||||
|
||||
Dropdown panels will be clipped if they're inside a container that has `overflow: auto|hidden`. The `hoist` attribute forces the panel to use a fixed positioning strategy, allowing it to break out of the container. In this case, the panel will be positioned relative to its [containing block](https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#Identifying_the_containing_block), which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
|
||||
|
@ -349,7 +439,6 @@ Dropdown panels will be clipped if they're inside a container that has `overflow
|
|||
import SlButton from '@shoelace-style/shoelace/dist/react/button';
|
||||
import SlDivider from '@shoelace-style/shoelace/dist/react/divider';
|
||||
import SlDropdown from '@shoelace-style/shoelace/dist/react/dropdown';
|
||||
import SlIcon from '@shoelace-style/shoelace/dist/react/icon';
|
||||
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
|
||||
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
|
||||
|
||||
|
|
|
@ -44,3 +44,112 @@ const App = () => (
|
|||
:::tip
|
||||
Menus are intended for system menus (dropdown menus, select menus, context menus, etc.). They should not be mistaken for navigation menus which serve a different purpose and have a different semantic meaning. If you're building navigation, use `<nav>` and `<a>` elements instead.
|
||||
:::
|
||||
|
||||
## Examples
|
||||
|
||||
### In Dropdowns
|
||||
|
||||
Menus work really well when used inside [dropdowns](/components/dropdown).
|
||||
|
||||
```html:preview
|
||||
<sl-dropdown>
|
||||
<sl-button slot="trigger" caret>Edit</sl-button>
|
||||
<sl-menu>
|
||||
<sl-menu-item value="cut">Cut</sl-menu-item>
|
||||
<sl-menu-item value="copy">Copy</sl-menu-item>
|
||||
<sl-menu-item value="paste">Paste</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-dropdown>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import SlButton from '@shoelace-style/shoelace/dist/react/button';
|
||||
import SlDropdown from '@shoelace-style/shoelace/dist/react/dropdown';
|
||||
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
|
||||
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
|
||||
|
||||
const App = () => (
|
||||
<SlDropdown>
|
||||
<SlButton slot="trigger" caret>Edit</SlButton>
|
||||
<SlMenu>
|
||||
<SlMenuItem value="cut">Cut</SlMenuItem>
|
||||
<SlMenuItem value="copy">Copy</SlMenuItem>
|
||||
<SlMenuItem value="paste">Paste</SlMenuItem>
|
||||
</SlMenu>
|
||||
</SlDropdown>
|
||||
);
|
||||
```
|
||||
|
||||
### Submenus
|
||||
|
||||
To create a submenu, nest an `<sl-menu slot="submenu">` in any [menu item](/components/menu-item).
|
||||
|
||||
```html:preview
|
||||
<sl-menu style="max-width: 200px;">
|
||||
<sl-menu-item value="undo">Undo</sl-menu-item>
|
||||
<sl-menu-item value="redo">Redo</sl-menu-item>
|
||||
<sl-divider></sl-divider>
|
||||
<sl-menu-item value="cut">Cut</sl-menu-item>
|
||||
<sl-menu-item value="copy">Copy</sl-menu-item>
|
||||
<sl-menu-item value="paste">Paste</sl-menu-item>
|
||||
<sl-divider></sl-divider>
|
||||
<sl-menu-item>
|
||||
Find
|
||||
<sl-menu slot="submenu">
|
||||
<sl-menu-item value="find">Find…</sl-menu-item>
|
||||
<sl-menu-item value="find-previous">Find Next</sl-menu-item>
|
||||
<sl-menu-item value="find-next">Find Previous</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-menu-item>
|
||||
<sl-menu-item>
|
||||
Transformations
|
||||
<sl-menu slot="submenu">
|
||||
<sl-menu-item value="uppercase">Make uppercase</sl-menu-item>
|
||||
<sl-menu-item value="lowercase">Make lowercase</sl-menu-item>
|
||||
<sl-menu-item value="capitalize">Capitalize</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-menu-item>
|
||||
</sl-menu>
|
||||
```
|
||||
|
||||
{% raw %}
|
||||
|
||||
```jsx:react
|
||||
import SlDivider from '@shoelace-style/shoelace/dist/react/divider';
|
||||
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
|
||||
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
|
||||
|
||||
const App = () => (
|
||||
<SlMenu style={{ maxWidth: '200px' }}>
|
||||
<SlMenuItem value="undo">Undo</SlMenuItem>
|
||||
<SlMenuItem value="redo">Redo</SlMenuItem>
|
||||
<SlDivider />
|
||||
<SlMenuItem value="cut">Cut</SlMenuItem>
|
||||
<SlMenuItem value="copy">Copy</SlMenuItem>
|
||||
<SlMenuItem value="paste">Paste</SlMenuItem>
|
||||
<SlDivider />
|
||||
<SlMenuItem>
|
||||
Find
|
||||
<SlMenu slot="submenu">
|
||||
<SlMenuItem value="find">Find…</SlMenuItem>
|
||||
<SlMenuItem value="find-previous">Find Next</SlMenuItem>
|
||||
<SlMenuItem value="find-next">Find Previous</SlMenuItem>
|
||||
</SlMenu>
|
||||
</SlMenuItem>
|
||||
<SlMenuItem>
|
||||
Transformations
|
||||
<SlMenu slot="submenu">
|
||||
<SlMenuItem value="uppercase">Make uppercase</SlMenuItem>
|
||||
<SlMenuItem value="lowercase">Make lowercase</SlMenuItem>
|
||||
<SlMenuItem value="capitalize">Capitalize</SlMenuItem>
|
||||
</SlMenu>
|
||||
</SlMenuItem>
|
||||
</SlMenu>
|
||||
);
|
||||
```
|
||||
|
||||
:::warning
|
||||
As a UX best practice, avoid using more than one level of submenus when possible.
|
||||
:::
|
||||
|
||||
{% endraw %}
|
||||
|
|
|
@ -383,10 +383,8 @@ The preferred placement of the select's listbox can be set with the `placement`
|
|||
```
|
||||
|
||||
```jsx:react
|
||||
import {
|
||||
SlOption,
|
||||
SlSelect
|
||||
} from '@shoelace-style/shoelace/dist/react';
|
||||
import SlOption from '@shoelace-style/shoelace/dist/react/option';
|
||||
import SlSelect from '@shoelace-style/shoelace/dist/react/select';
|
||||
|
||||
const App = () => (
|
||||
<SlSelect placement="top">
|
||||
|
|
|
@ -14,9 +14,12 @@ New versions of Shoelace are released as-needed and generally occur when a criti
|
|||
|
||||
## Next
|
||||
|
||||
- Added support for submenus in `<sl-menu-item>` [#1410]
|
||||
- Added the `--submenu-offset` custom property to `<sl-menu-item>` [#1410]
|
||||
- Fixed type issues with the `ref` attribute in React Wrappers. [#1526]
|
||||
- Fixed a regression that caused `<sl-radio-button>` to render incorrectly with gaps [#1523]
|
||||
- Improved expand/collapse behavior of `<sl-tree>` to work more like users expect [#1521]
|
||||
- Improved `<sl-menu-item>` so labels truncate properly instead of getting chopped and overflowing
|
||||
|
||||
## 2.7.0
|
||||
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getTextContent } from '../../internal/slot.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';
|
||||
|
||||
|
@ -15,10 +18,12 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @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.
|
||||
|
@ -26,10 +31,15 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @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 };
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon,
|
||||
'sl-popup': SlPopup
|
||||
};
|
||||
|
||||
private cachedTextLabel: string;
|
||||
|
||||
|
@ -48,6 +58,22 @@ export default class SlMenuItem extends ShoelaceElement {
|
|||
/** 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();
|
||||
|
||||
|
@ -64,6 +90,19 @@ export default class SlMenuItem extends ShoelaceElement {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -102,16 +141,28 @@ export default class SlMenuItem extends ShoelaceElement {
|
|||
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': false // reserved for future use
|
||||
'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>
|
||||
|
@ -124,8 +175,10 @@ export default class SlMenuItem extends ShoelaceElement {
|
|||
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
|
||||
|
||||
<span part="submenu-icon" class="menu-item__chevron">
|
||||
<sl-icon name="chevron-right" library="system" aria-hidden="true"></sl-icon>
|
||||
<sl-icon name=${isRtl ? 'chevron-left' : 'chevron-right'} library="system" aria-hidden="true"></sl-icon>
|
||||
</span>
|
||||
|
||||
${this.submenuController.renderSubmenu()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ export default css`
|
|||
${componentStyles}
|
||||
|
||||
:host {
|
||||
--submenu-offset: -2px;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
@ -38,6 +40,8 @@ export default css`
|
|||
.menu-item .menu-item__label {
|
||||
flex: 1 1 auto;
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-item .menu-item__prefix {
|
||||
|
@ -64,7 +68,8 @@ export default css`
|
|||
outline: none;
|
||||
}
|
||||
|
||||
:host(:hover:not([aria-disabled='true'], :focus-visible)) .menu-item {
|
||||
:host(:hover:not([aria-disabled='true'], :focus-visible)) .menu-item,
|
||||
.menu-item--submenu-expanded {
|
||||
background-color: var(--sl-color-neutral-100);
|
||||
color: var(--sl-color-neutral-1000);
|
||||
}
|
||||
|
@ -91,6 +96,17 @@ export default css`
|
|||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Add elevation and z-index to submenus */
|
||||
sl-popup::part(popup) {
|
||||
box-shadow: var(--sl-shadow-large);
|
||||
z-index: var(--sl-z-index-dropdown);
|
||||
margin-left: var(--submenu-offset);
|
||||
}
|
||||
|
||||
.menu-item--rtl sl-popup::part(popup) {
|
||||
margin-left: calc(-1 * var(--submenu-offset));
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
:host(:hover:not([aria-disabled='true'])) .menu-item,
|
||||
:host(:focus-visible) .menu-item {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type SlMenuItem from './menu-item';
|
||||
import type SlSelectEvent from '../../events/sl-select';
|
||||
|
||||
describe('<sl-menu-item>', () => {
|
||||
it('should pass accessibility tests', async () => {
|
||||
|
@ -18,6 +20,21 @@ describe('<sl-menu-item>', () => {
|
|||
await expect(el).to.be.accessible();
|
||||
});
|
||||
|
||||
it('should pass accessibility tests when using a submenu', async () => {
|
||||
const el = await fixture<SlMenuItem>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item>
|
||||
Submenu
|
||||
<sl-menu slot="submenu">
|
||||
<sl-menu-item>Submenu Item 1</sl-menu-item>
|
||||
<sl-menu-item>Submenu Item 2</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
await expect(el).to.be.accessible();
|
||||
});
|
||||
|
||||
it('should have the correct default properties', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
|
||||
|
||||
|
@ -59,4 +76,98 @@ describe('<sl-menu-item>', () => {
|
|||
|
||||
expect(getComputedStyle(item1).display).to.equal('none');
|
||||
});
|
||||
|
||||
it('should not render a sl-popup if the slot="submenu" attribute is missing, but the slot should exist in the component and be hidden.', async () => {
|
||||
const menu = await fixture<SlMenuItem>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item>
|
||||
Item 1
|
||||
<sl-menu>
|
||||
<sl-menu-item> Nested Item 1 </sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
|
||||
const menuItem: HTMLElement = menu.querySelector('sl-menu-item')!;
|
||||
expect(menuItem.shadowRoot!.querySelector('sl-popup')).to.be.null;
|
||||
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
|
||||
expect(submenuSlot.hidden).to.be.true;
|
||||
});
|
||||
|
||||
it('should render an sl-popup if the slot="submenu" attribute is present', async () => {
|
||||
const menu = await fixture<SlMenuItem>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item id="test">
|
||||
Item 1
|
||||
<sl-menu slot="submenu">
|
||||
<sl-menu-item> Nested Item 1 </sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
|
||||
const menuItem = menu.querySelector('sl-menu-item')!;
|
||||
expect(menuItem.shadowRoot!.querySelector('sl-popup')).to.be.not.null;
|
||||
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
|
||||
expect(submenuSlot.hidden).to.be.false;
|
||||
});
|
||||
|
||||
it('should focus on first menuitem of submenu if ArrowRight is pressed on parent menuitem', async () => {
|
||||
const menu = await fixture<SlMenuItem>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item id="item-1">
|
||||
Submenu
|
||||
<sl-menu slot="submenu">
|
||||
<sl-menu-item value="submenu-item-1"> Nested Item 1 </sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
|
||||
const selectHandler = sinon.spy((event: SlSelectEvent) => {
|
||||
const item = event.detail.item;
|
||||
expect(item.value).to.equal('submenu-item-1');
|
||||
});
|
||||
menu.addEventListener('sl-select', selectHandler);
|
||||
|
||||
const submenu = menu.querySelector('sl-menu-item');
|
||||
submenu!.focus();
|
||||
await menu.updateComplete;
|
||||
await sendKeys({ press: 'ArrowRight' });
|
||||
await menu.updateComplete;
|
||||
await sendKeys({ press: 'Enter' });
|
||||
await menu.updateComplete;
|
||||
// Once for each menu element.
|
||||
expect(selectHandler).to.have.been.calledTwice;
|
||||
});
|
||||
|
||||
it('should focus on outer menu if ArrowRight is pressed on nested menuitem', async () => {
|
||||
const menu = await fixture<SlMenuItem>(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item value="outer-item-1">
|
||||
Submenu
|
||||
<sl-menu slot="submenu">
|
||||
<sl-menu-item value="inner-item-1"> Nested Item 1 </sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
|
||||
const focusHandler = sinon.spy((event: FocusEvent) => {
|
||||
expect(event.target.value).to.equal('outer-item-1');
|
||||
expect(event.relatedTarget.value).to.equal('inner-item-1');
|
||||
});
|
||||
|
||||
const outerItem = menu.querySelector('sl-menu-item');
|
||||
outerItem!.focus();
|
||||
await menu.updateComplete;
|
||||
await sendKeys({ press: 'ArrowRight' });
|
||||
|
||||
outerItem.addEventListener('focus', focusHandler);
|
||||
await menu.updateComplete;
|
||||
await sendKeys({ press: 'ArrowLeft' });
|
||||
await menu.updateComplete;
|
||||
expect(focusHandler).to.have.been.calledOnce;
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,262 @@
|
|||
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
||||
import { type HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { type LocalizeController } from '../../utilities/localize.js';
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import type SlMenuItem from './menu-item.js';
|
||||
import type SlPopup from '../popup/popup.js';
|
||||
|
||||
/** A reactive controller to manage the registration of event listeners for submenus. */
|
||||
export class SubmenuController implements ReactiveController {
|
||||
private host: ReactiveControllerHost & SlMenuItem;
|
||||
private popupRef: Ref<SlPopup> = createRef();
|
||||
private enableSubmenuTimer = -1;
|
||||
private isConnected = false;
|
||||
private isPopupConnected = false;
|
||||
private skidding = 0;
|
||||
private readonly hasSlotController: HasSlotController;
|
||||
private readonly localize: LocalizeController;
|
||||
private readonly submenuOpenDelay = 100;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & SlMenuItem,
|
||||
hasSlotController: HasSlotController,
|
||||
localize: LocalizeController
|
||||
) {
|
||||
(this.host = host).addController(this);
|
||||
this.hasSlotController = hasSlotController;
|
||||
this.localize = localize;
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
if (this.hasSlotController.test('submenu') && !this.host.disabled) {
|
||||
this.addListeners();
|
||||
}
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this.removeListeners();
|
||||
}
|
||||
|
||||
hostUpdated() {
|
||||
if (this.hasSlotController.test('submenu') && !this.host.disabled) {
|
||||
this.addListeners();
|
||||
this.updateSkidding();
|
||||
} else {
|
||||
this.removeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
private addListeners() {
|
||||
if (!this.isConnected) {
|
||||
this.host.addEventListener('mouseover', this.handleMouseOver);
|
||||
this.host.addEventListener('keydown', this.handleKeyDown);
|
||||
this.host.addEventListener('click', this.handleClick);
|
||||
this.host.addEventListener('focusout', this.handleFocusOut);
|
||||
this.isConnected = true;
|
||||
}
|
||||
|
||||
// The popup does not seem to get wired when the host is
|
||||
// connected, so manage its listeners separately.
|
||||
if (!this.isPopupConnected) {
|
||||
if (this.popupRef.value) {
|
||||
this.popupRef.value.addEventListener('mouseover', this.handlePopupMouseover);
|
||||
this.isPopupConnected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeListeners() {
|
||||
if (this.isConnected) {
|
||||
this.host.removeEventListener('mouseover', this.handleMouseOver);
|
||||
this.host.removeEventListener('keydown', this.handleKeyDown);
|
||||
this.host.removeEventListener('click', this.handleClick);
|
||||
this.host.removeEventListener('focusout', this.handleFocusOut);
|
||||
this.isConnected = false;
|
||||
}
|
||||
if (this.isPopupConnected) {
|
||||
if (this.popupRef.value) {
|
||||
this.popupRef.value.removeEventListener('mouseover', this.handlePopupMouseover);
|
||||
this.isPopupConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseOver = () => {
|
||||
if (this.hasSlotController.test('submenu')) {
|
||||
this.enableSubmenu();
|
||||
}
|
||||
};
|
||||
|
||||
private handleSubmenuEntry(event: KeyboardEvent) {
|
||||
// Pass focus to the first menu-item in the submenu.
|
||||
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
|
||||
|
||||
// Missing slot
|
||||
if (!submenuSlot) {
|
||||
console.error('Cannot activate a submenu if no corresponding menuitem can be found.', this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Menus
|
||||
let menuItems: NodeListOf<Element> | null = null;
|
||||
for (const elt of submenuSlot.assignedElements()) {
|
||||
menuItems = elt.querySelectorAll("sl-menu-item, [role^='menuitem']");
|
||||
if (menuItems.length !== 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!menuItems || menuItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
menuItems[0].setAttribute('tabindex', '0');
|
||||
for (let i = 1; i !== menuItems.length; ++i) {
|
||||
menuItems[i].setAttribute('tabindex', '-1');
|
||||
}
|
||||
|
||||
// Open the submenu (if not open), and set focus to first menuitem.
|
||||
if (this.popupRef.value) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.popupRef.value.active) {
|
||||
if (menuItems[0] instanceof HTMLElement) {
|
||||
menuItems[0].focus();
|
||||
}
|
||||
} else {
|
||||
this.enableSubmenu(false);
|
||||
this.host.updateComplete.then(() => {
|
||||
if (menuItems![0] instanceof HTMLElement) {
|
||||
menuItems![0].focus();
|
||||
}
|
||||
});
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Focus on the first menu-item of a submenu.
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
case 'Tab':
|
||||
this.disableSubmenu();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
// Either focus is currently on the host element or a child
|
||||
if (event.target !== this.host) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.host.focus();
|
||||
this.disableSubmenu();
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
this.handleSubmenuEntry(event);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private handleClick = (event: MouseEvent) => {
|
||||
// Clicking on the item which heads the menu does nothing, otherwise hide submenu and propagate
|
||||
if (event.target === this.host) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (
|
||||
event.target instanceof Element &&
|
||||
(event.target.tagName === 'sl-menu-item' || event.target.role?.startsWith('menuitem'))
|
||||
) {
|
||||
this.disableSubmenu();
|
||||
}
|
||||
};
|
||||
|
||||
// Close this submenu on focus outside of the parent or any descendants.
|
||||
private handleFocusOut = (event: FocusEvent) => {
|
||||
if (event.relatedTarget && event.relatedTarget instanceof Element && this.host.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
this.disableSubmenu();
|
||||
};
|
||||
|
||||
// Prevent the parent menu-item from getting focus on mouse movement on the submenu
|
||||
private handlePopupMouseover = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
private setSubmenuState(state: boolean) {
|
||||
if (this.popupRef.value) {
|
||||
if (this.popupRef.value.active !== state) {
|
||||
this.popupRef.value.active = state;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shows the submenu. Supports disabling the opening delay, e.g. for keyboard events that want to set the focus to the
|
||||
// newly opened menu.
|
||||
private enableSubmenu(delay = true) {
|
||||
if (delay) {
|
||||
this.enableSubmenuTimer = window.setTimeout(() => {
|
||||
this.setSubmenuState(true);
|
||||
}, this.submenuOpenDelay);
|
||||
} else {
|
||||
this.setSubmenuState(true);
|
||||
}
|
||||
}
|
||||
|
||||
private disableSubmenu() {
|
||||
clearTimeout(this.enableSubmenuTimer);
|
||||
this.setSubmenuState(false);
|
||||
}
|
||||
|
||||
// Calculate the space the top of a menu takes-up, for aligning the popup menu-item with the activating element.
|
||||
private updateSkidding(): void {
|
||||
// .computedStyleMap() not always available.
|
||||
if (!this.host.parentElement?.computedStyleMap) {
|
||||
return;
|
||||
}
|
||||
const styleMap: StylePropertyMapReadOnly = this.host.parentElement.computedStyleMap();
|
||||
const attrs: string[] = ['padding-top', 'border-top-width', 'margin-top'];
|
||||
|
||||
const skidding = attrs.reduce((accumulator, attr) => {
|
||||
const styleValue: CSSStyleValue = styleMap.get(attr) ?? new CSSUnitValue(0, 'px');
|
||||
const unitValue = styleValue instanceof CSSUnitValue ? styleValue : new CSSUnitValue(0, 'px');
|
||||
const pxValue = unitValue.to('px');
|
||||
return accumulator - pxValue.value;
|
||||
}, 0);
|
||||
|
||||
this.skidding = skidding;
|
||||
}
|
||||
|
||||
isExpanded(): boolean {
|
||||
return this.popupRef.value ? this.popupRef.value.active : false;
|
||||
}
|
||||
|
||||
renderSubmenu() {
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
|
||||
// Always render the slot, but conditionally render the outer <sl-popup>
|
||||
if (!this.isConnected) {
|
||||
return html` <slot name="submenu" hidden></slot> `;
|
||||
}
|
||||
|
||||
return html`
|
||||
<sl-popup
|
||||
${ref(this.popupRef)}
|
||||
placement=${isLtr ? 'right-start' : 'left-start'}
|
||||
anchor="anchor"
|
||||
flip
|
||||
flip-fallback-strategy="best-fit"
|
||||
skidding="${this.skidding}"
|
||||
strategy="fixed"
|
||||
>
|
||||
<slot name="submenu"></slot>
|
||||
</sl-popup>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
import { html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlMenuItem from '../menu-item/menu-item.component.js';
|
||||
import styles from './menu.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlMenuItem from '../menu-item/menu-item.js';
|
||||
export interface MenuSelectEventDetail {
|
||||
item: SlMenuItem;
|
||||
}
|
||||
|
@ -29,13 +29,12 @@ export default class SlMenu extends ShoelaceElement {
|
|||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const item = target.closest('sl-menu-item');
|
||||
|
||||
if (!item || item.disabled || item.inert) {
|
||||
if (!(event.target instanceof SlMenuItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: SlMenuItem = event.target;
|
||||
|
||||
if (item.type === 'checkbox') {
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
|
@ -48,19 +47,21 @@ export default class SlMenu extends ShoelaceElement {
|
|||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
const item = this.getCurrentItem();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Simulate a click to support @click handlers on menu items that also work with the keyboard
|
||||
item?.click();
|
||||
}
|
||||
|
||||
// Move the selection when pressing down or up
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
const items = this.getAllItems();
|
||||
const activeItem = this.getCurrentItem();
|
||||
let index = activeItem ? items.indexOf(activeItem) : 0;
|
||||
|
||||
if (items.length > 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
index++;
|
||||
|
|
Ładowanie…
Reference in New Issue