* [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>
15 KiB
meta, layout
| meta | layout | ||||
|---|---|---|---|---|---|
|
component |
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 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). The API gives you complete control over showing, hiding, and positioning the panel.
<sl-dropdown>
<sl-button slot="trigger" caret>Dropdown</sl-button>
<sl-menu>
<sl-menu-item>Dropdown Item 1</sl-menu-item>
<sl-menu-item>Dropdown Item 2</sl-menu-item>
<sl-menu-item>Dropdown Item 3</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item type="checkbox" checked>Checkbox</sl-menu-item>
<sl-menu-item disabled>Disabled</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item>
Prefix
<sl-icon slot="prefix" name="gift"></sl-icon>
</sl-menu-item>
<sl-menu-item>
Suffix Icon
<sl-icon slot="suffix" name="heart"></sl-icon>
</sl-menu-item>
</sl-menu>
</sl-dropdown>
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';
const App = () => (
<SlDropdown>
<SlButton slot="trigger" caret>
Dropdown
</SlButton>
<SlMenu>
<SlMenuItem>Dropdown Item 1</SlMenuItem>
<SlMenuItem>Dropdown Item 2</SlMenuItem>
<SlMenuItem>Dropdown Item 3</SlMenuItem>
<SlDivider />
<SlMenuItem type="checkbox" checked>
Checkbox
</SlMenuItem>
<SlMenuItem disabled>Disabled</SlMenuItem>
<SlDivider />
<SlMenuItem>
Prefix
<SlIcon slot="prefix" name="gift" />
</SlMenuItem>
<SlMenuItem>
Suffix Icon
<SlIcon slot="suffix" name="heart" />
</SlMenuItem>
</SlMenu>
</SlDropdown>
);
Examples
Getting the Selected Item
When dropdowns are used with menus, you can listen for the sl-select 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.
<div class="dropdown-selection">
<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>
</div>
<script>
const container = document.querySelector('.dropdown-selection');
const dropdown = container.querySelector('sl-dropdown');
dropdown.addEventListener('sl-select', event => {
const selectedItem = event.detail.item;
console.log(selectedItem.value);
});
</script>
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 = () => {
function handleSelect(event) {
const selectedItem = event.detail.item;
console.log(selectedItem.value);
}
return (
<SlDropdown>
<SlButton slot="trigger" caret>
Edit
</SlButton>
<SlMenu onSlSelect={handleSelect}>
<SlMenuItem value="cut">Cut</SlMenuItem>
<SlMenuItem value="copy">Copy</SlMenuItem>
<SlMenuItem value="paste">Paste</SlMenuItem>
</SlMenu>
</SlDropdown>
);
};
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.
<div class="dropdown-selection-alt">
<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>
</div>
<script>
const container = document.querySelector('.dropdown-selection-alt');
const cut = container.querySelector('sl-menu-item[value="cut"]');
const copy = container.querySelector('sl-menu-item[value="copy"]');
const paste = container.querySelector('sl-menu-item[value="paste"]');
cut.addEventListener('click', () => console.log('cut'));
copy.addEventListener('click', () => console.log('copy'));
paste.addEventListener('click', () => console.log('paste'));
</script>
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 = () => {
function handleCut() {
console.log('cut');
}
function handleCopy() {
console.log('copy');
}
function handlePaste() {
console.log('paste');
}
return (
<SlDropdown>
<SlButton slot="trigger" caret>
Edit
</SlButton>
<SlMenu>
<SlMenuItem onClick={handleCut}>Cut</SlMenuItem>
<SlMenuItem onClick={handleCopy}>Copy</SlMenuItem>
<SlMenuItem onClick={handlePaste}>Paste</SlMenuItem>
</SlMenu>
</SlDropdown>
);
};
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.
<sl-dropdown placement="top-start">
<sl-button slot="trigger" caret>Edit</sl-button>
<sl-menu>
<sl-menu-item>Cut</sl-menu-item>
<sl-menu-item>Copy</sl-menu-item>
<sl-menu-item>Paste</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item>Find</sl-menu-item>
<sl-menu-item>Replace</sl-menu-item>
</sl-menu>
</sl-dropdown>
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 App = () => (
<SlDropdown placement="top-start">
<SlButton slot="trigger" caret>
Edit
</SlButton>
<SlMenu>
<SlMenuItem>Cut</SlMenuItem>
<SlMenuItem>Copy</SlMenuItem>
<SlMenuItem>Paste</SlMenuItem>
<SlDivider />
<SlMenuItem>Find</SlMenuItem>
<SlMenuItem>Replace</SlMenuItem>
</SlMenu>
</SlDropdown>
);
Distance
The distance from the panel to the trigger can be customized using the distance attribute. This value is specified in pixels.
<sl-dropdown distance="30">
<sl-button slot="trigger" caret>Edit</sl-button>
<sl-menu>
<sl-menu-item>Cut</sl-menu-item>
<sl-menu-item>Copy</sl-menu-item>
<sl-menu-item>Paste</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item>Find</sl-menu-item>
<sl-menu-item>Replace</sl-menu-item>
</sl-menu>
</sl-dropdown>
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 App = () => (
<SlDropdown distance={30}>
<SlButton slot="trigger" caret>
Edit
</SlButton>
<SlMenu>
<SlMenuItem>Cut</SlMenuItem>
<SlMenuItem>Copy</SlMenuItem>
<SlMenuItem>Paste</SlMenuItem>
<SlDivider />
<SlMenuItem>Find</SlMenuItem>
<SlMenuItem>Replace</SlMenuItem>
</SlMenu>
</SlDropdown>
);
Skidding
The offset of the panel along the trigger can be customized using the skidding attribute. This value is specified in pixels.
<sl-dropdown skidding="30">
<sl-button slot="trigger" caret>Edit</sl-button>
<sl-menu>
<sl-menu-item>Cut</sl-menu-item>
<sl-menu-item>Copy</sl-menu-item>
<sl-menu-item>Paste</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item>Find</sl-menu-item>
<sl-menu-item>Replace</sl-menu-item>
</sl-menu>
</sl-dropdown>
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 App = () => (
<SlDropdown skidding={30}>
<SlButton slot="trigger" caret>
Edit
</SlButton>
<SlMenu>
<SlMenuItem>Cut</SlMenuItem>
<SlMenuItem>Copy</SlMenuItem>
<SlMenuItem>Paste</SlMenuItem>
<SlDivider />
<SlMenuItem>Find</SlMenuItem>
<SlMenuItem>Replace</SlMenuItem>
</SlMenu>
</SlDropdown>
);
Submenus
To create a submenu, nest an <sl-menu slot="submenu"> element in a menu item.
<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>
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, which is usually the viewport unless an ancestor uses a transform, perspective, or filter. Refer to this page for more details.
<div class="dropdown-hoist">
<sl-dropdown>
<sl-button slot="trigger" caret>No Hoist</sl-button>
<sl-menu>
<sl-menu-item>Item 1</sl-menu-item>
<sl-menu-item>Item 2</sl-menu-item>
<sl-menu-item>Item 3</sl-menu-item>
</sl-menu>
</sl-dropdown>
<sl-dropdown hoist>
<sl-button slot="trigger" caret>Hoist</sl-button>
<sl-menu>
<sl-menu-item>Item 1</sl-menu-item>
<sl-menu-item>Item 2</sl-menu-item>
<sl-menu-item>Item 3</sl-menu-item>
</sl-menu>
</sl-dropdown>
</div>
<style>
.dropdown-hoist {
position: relative;
border: solid 2px var(--sl-panel-border-color);
padding: var(--sl-spacing-medium);
overflow: hidden;
}
</style>
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 = () => (
<>
<div className="dropdown-hoist">
<SlDropdown>
<SlButton slot="trigger" caret>
No Hoist
</SlButton>
<SlMenu>
<SlMenuItem>Item 1</SlMenuItem>
<SlMenuItem>Item 2</SlMenuItem>
<SlMenuItem>Item 3</SlMenuItem>
</SlMenu>
</SlDropdown>
<SlDropdown hoist>
<SlButton slot="trigger" caret>
Hoist
</SlButton>
<SlMenu>
<SlMenuItem>Item 1</SlMenuItem>
<SlMenuItem>Item 2</SlMenuItem>
<SlMenuItem>Item 3</SlMenuItem>
</SlMenu>
</SlDropdown>
</div>
<style>{css}</style>
</>
);