Compare commits

...

5 Commits

Author SHA1 Message Date
Cory LaViska
8ee519d40a fix positioning 2021-10-30 15:54:56 -04:00
Cory LaViska
6bc17d48c3 update examples 2021-10-29 18:35:24 -04:00
Cory LaViska
a1263f1b9d add experimental context menu 2021-10-29 18:32:45 -04:00
Cory LaViska
d69ebab765 update examples 2021-10-29 18:32:26 -04:00
Cory LaViska
0504946dac refactor popper creation 2021-10-29 18:32:12 -04:00
9 changed files with 546 additions and 113 deletions

View File

@@ -21,6 +21,7 @@
- [Card](/components/card)
- [Checkbox](/components/checkbox)
- [Color Picker](/components/color-picker)
- [Context Menu](/components/context-menu)
- [Details](/components/details)
- [Dialog](/components/dialog)
- [Divider](/components/divider)

View File

@@ -0,0 +1,140 @@
# Context Menu
[component-header:sl-context-menu]
Context menus offer additional options through a menu that opens at the pointer's location, usually activated by a right-click.
Context menus are designed to work with [menus](/components/menu) and [menu items](/components/menu-item). The menu must include `slot="menu"`. Other content you provide will be part of the context menu's target area.
```html preview
<sl-context-menu>
<div style="height: 200px; background: rgb(var(--sl-color-neutral-100)); display: flex; align-items: center; justify-content: center; padding: 1rem;">
Right-click to activate the context menu
</div>
<sl-menu slot="menu">
<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-menu-item value="delete">Delete</sl-menu-item>
</sl-menu>
</sl-context-menu>
```
## Examples
### Handling Selections
The [menu component](/components/menu) emits an `sl-select` event when a menu item is selected. You can use this to handle selections. The selected item will be available in `event.detail.item`.
```html preview
<div class="context-menu-selections">
<sl-context-menu>
<div style="height: 200px; background: rgb(var(--sl-color-neutral-100)); display: flex; align-items: center; justify-content: center; padding: 1rem;">
Right-click to activate the context menu
</div>
<sl-menu slot="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-context-menu>
</div>
<script>
const container = document.querySelector('.context-menu-selections');
const menu = container.querySelector('sl-menu');
const result = container.querySelector('.result');
menu.addEventListener('sl-select', event => {
console.log(`You selected: ${event.detail.item.value}`);
});
</script>
```
### Inline
The context menu uses `display: contents`, so it will assume the shape of the content you slot in.
```html preview
<sl-context-menu>
<span style="background: rgb(var(--sl-color-neutral-100)); padding: .5rem 1rem;">
Right-click here
</span>
<sl-menu slot="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-context-menu>
```
### Placement
The preferred placement of the context menu can be set with the `placement` attribute. Note that the actual position may vary to ensure the menu remains in the viewport.
```html preview
<sl-context-menu placement="top-end">
<div style="height: 200px; background: rgb(var(--sl-color-neutral-100)); display: flex; align-items: center; justify-content: center; padding: 1rem;">
Right-click to activate the context menu
</div>
<sl-menu slot="menu">
<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-menu-item value="delete">Delete</sl-menu-item>
</sl-menu>
</sl-context-menu>
```
### Detecting the Target Item
A single context menu can wrap a number of items. To detect the item that activated the context menu...
TODO
```html preview
<div class="context-menu-detecting">
<sl-context-menu>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
<li>Item 4</li>
<li>Item 5</li>
</ul>
<sl-menu slot="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-context-menu>
</div>
<style>
.context-menu-detecting ul {
max-width: 300px;
list-style: none;
padding: 0;
margin: 0;
}
.context-menu-detecting li {
background: rgb(var(--sl-color-neutral-100));
padding: .5rem 1rem;
margin: 0 0 2px 0;
}
</style>
```
[component-metadata:sl-context-menu]

View File

@@ -33,6 +33,33 @@ Dropdowns are designed to work well with [menus](/components/menu) to provide a
## Examples
### Getting the Selected Item
When dropdowns are used with [menus](/components/menu), 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.
```html preview
<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>
```
### 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.
@@ -121,57 +148,4 @@ Dropdown panels will be clipped if they're inside a container that has `overflow
</style>
```
### Getting the Selected Item
When dropdowns are used with [menus](/components/menu), 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.
```html preview
<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>
```
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 preview
<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>
```
[component-metadata:sl-dropdown]

View File

@@ -8,9 +8,11 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
## Next
- Added experimental `<sl-context-menu>` component
- Added eye dropper to `<sl-color-picker>` when the browser supports the [EyeDropper API](https://wicg.github.io/eyedropper-api/)
- Fixed a bug in `<sl-button-group>` where buttons groups with only one button would have an incorrect border radius
- Improved the `<sl-color-picker>` trigger's border in dark mode
- Refactored positioning logic in `<sl-dropdown>` so Popper is only active when the menu is open
- Updated to Lit 2.0.2
## 2.0.0-beta.58

View File

@@ -0,0 +1,43 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
export default css`
${componentStyles}
:host {
display: contents;
}
::slotted(sl-menu) {
min-width: 180px;
background: rgb(var(--sl-panel-background-color));
border: solid var(--sl-panel-border-width) rgb(var(--sl-panel-border-color));
border-radius: var(--sl-border-radius-medium);
box-shadow: var(--sl-shadow-large);
}
.context-menu {
position: relative;
z-index: var(--sl-z-index-dropdown);
}
.context-menu__locater {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
pointer-events: none;
}
.dropdown__positioner {
position: absolute;
}
.context-menu__menu {
position: relative;
top: 0;
left: 0;
pointer-events: all;
}
`;

View File

@@ -0,0 +1,13 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
// import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlContextMenu from './context-menu';
describe('<sl-context-menu>', () => {
it('should render a component', async () => {
const el = await fixture(html` <sl-context-menu></sl-context-menu> `);
expect(el).to.exist;
});
});

View File

@@ -0,0 +1,292 @@
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { emit, waitForEvent } from '../../internal/event';
import { watch } from '../../internal/watch';
import { Instance as PopperInstance, createPopper } from '@popperjs/core/dist/esm';
import { animateTo, stopAnimations } from '../../internal/animate';
import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry';
import type SlMenu from '../menu/menu';
import styles from './context-menu.styles';
import '../menu/menu';
/**
* @since 2.0
* @status experimental
*
* @dependency sl-menu
*
* @event sl-event-name - Emitted as an example.
*
* @slot - Content that will activate the context menu when right-clicked.
* @slot menu - The menu to show when the context menu is activated, an `<sl-menu>` element.
*
* @event sl-show - Emitted when the context menu opens.
* @event sl-after-show - Emitted after the context menu opens and all animations are complete.
* @event sl-hide - Emitted when the context menu closes.
* @event sl-after-hide - Emitted after the context menu closes and all animations are complete.
*
* @animation contextMenu.show - The animation to use when showing the context menu.
* @animation contextMenu.hide - The animation to use when hiding the context menu.
*/
@customElement('sl-context-menu')
export default class SlContextMenu extends LitElement {
static styles = styles;
@query('.context-menu') wrapper: HTMLElement;
@query('.context-menu__locater') locater: HTMLElement;
@query('.context-menu__menu') menu: HTMLSlotElement;
@query('.context-menu__positioner') positioner: HTMLElement;
private popover: PopperInstance;
/**
* The preferred placement of the context menu. Note that the actual placement may vary as needed to keep the menu
* inside of the viewport.
*/
@property() placement:
| 'top'
| 'top-start'
| 'top-end'
| 'right'
| 'right-start'
| 'right-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end' = 'bottom-start';
/** Disables the context menu so it won't show when triggered. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** The distance in pixels from which to offset the context menu away from its target. */
@property({ type: Number }) distance = 0;
/** Indicates whether or not the context menu is open. You can use this in lieu of the show/hide methods. */
@property({ type: Boolean, reflect: true }) open = false;
/** The distance in pixels from which to offset the context menu along its target. */
@property({ type: Number }) skidding = 0;
/**
* Enable this option to prevent the menu from being clipped when the component is placed inside a container with
* `overflow: auto|hidden|scroll`.
*/
@property({ type: Boolean }) hoist = false;
connectedCallback() {
super.connectedCallback();
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
}
firstUpdated() {
this.menu.hidden = !this.open;
}
getMenu() {
const slot = this.menu.querySelector('slot')!;
return slot.assignedElements({ flatten: true }).filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as SlMenu;
}
async handleContextMenu(event: MouseEvent) {
const target = event.target as HTMLElement;
const targetRect = target.getBoundingClientRect();
const wrapperRect = this.wrapper.getBoundingClientRect();
const { offsetX, offsetY } = event;
const x = targetRect.left + offsetX - wrapperRect.left;
const y = targetRect.top + offsetY - wrapperRect.top;
event.preventDefault();
if (this.open) {
await this.hide();
}
this.show(x, y);
}
handleDocumentKeyDown(event: KeyboardEvent) {
const menu = this.getMenu();
const menuItems = menu ? menu.getAllItems() : [];
const firstMenuItem = menuItems[0];
const lastMenuItem = menuItems[menuItems.length - 1];
// Close when escape is pressed
if (event.key === 'Escape') {
this.hide();
return;
}
// Forward key presses that don't originate from the menu to allow keyboard selection and type-to-select
if (menu && !event.composedPath().includes(this.menu)) {
// Focus on a menu item
if (['ArrowDown', 'Home'].includes(event.key) && firstMenuItem) {
event.preventDefault();
const menu = this.getMenu();
menu.setCurrentItem(firstMenuItem);
firstMenuItem.focus();
return;
}
if (['ArrowUp', 'End'].includes(event.key) && lastMenuItem) {
event.preventDefault();
menu.setCurrentItem(lastMenuItem);
lastMenuItem.focus();
return;
}
// Other keys bring focus to the menu and initiate type-to-select behavior
const ignoredKeys = ['Tab', 'Shift', 'Meta', 'Ctrl', 'Alt'];
if (!ignoredKeys.includes(event.key)) {
menu.typeToSelect(event.key);
return;
}
}
}
handleDocumentMouseDown(event: MouseEvent) {
const path = event.composedPath() as Array<EventTarget>;
//
// Close the context menu when clicking outside of it. We use a setTimeout here because mousedown fires before
// contextmenu and, if the menu is already open and the user-right clicks again, we want the menu to re-open in the
// new position instead of closing.
//
setTimeout(() => {
if (this.open && !path.includes(this.menu)) {
this.hide();
return;
}
});
}
handleMenuSelect() {
// Close the context menu when a menu item is selected
this.hide();
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.disabled) {
return;
}
if (this.open) {
// Show
emit(this, 'sl-show');
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
await stopAnimations(this);
this.popover = createPopper(this.locater, this.positioner, {
placement: this.placement,
strategy: this.hoist ? 'fixed' : 'absolute',
modifiers: [
{
name: 'flip',
options: {
boundary: 'viewport'
}
},
{
name: 'offset',
options: {
offset: [this.skidding, this.distance]
}
}
]
});
this.menu.hidden = false;
const { keyframes, options } = getAnimation(this, 'contextMenu.show');
await animateTo(this.menu, keyframes, options);
emit(this, 'sl-after-show');
} else {
// Hide
emit(this, 'sl-hide');
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
await stopAnimations(this);
const { keyframes, options } = getAnimation(this, 'contextMenu.hide');
await animateTo(this.menu, keyframes, options);
this.menu.hidden = true;
this.locater.style.top = '0px';
this.locater.style.left = '0px';
this.popover.destroy();
emit(this, 'sl-after-hide');
}
}
/** Shows the context menu */
async show(offsetX?: number, offsetY?: number) {
if (this.open) {
return;
}
this.locater.style.top = `${offsetY || 0}px`;
this.locater.style.left = `${offsetX || 0}px`;
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dropdown panel */
async hide() {
if (!this.open) {
return;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
return html`
<div class="context-menu">
<slot @contextmenu=${this.handleContextMenu}></slot>
<div class="context-menu__locater"></div>
<!-- Position the menu with a wrapper since the popover makes use of translate. This let's us add animations
on the menu without interfering with the position. -->
<div class="context-menu__positioner">
<div class="context-menu__menu" hidden @sl-select=${this.handleMenuSelect}>
<slot name="menu"></slot>
</div>
</div>
</div>
`;
}
}
setDefaultAnimation('contextMenu.show', {
keyframes: [
{ opacity: 0, transform: 'scale(0.9)' },
{ opacity: 1, transform: 'scale(1)' }
],
options: { duration: 50, easing: 'ease' }
});
setDefaultAnimation('contextMenu.hide', {
keyframes: [
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(0.9)' }
],
options: { duration: 150, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-context-menu': SlContextMenu;
}
}

View File

@@ -100,28 +100,6 @@ export default class SlDropdown extends LitElement {
if (!this.containingElement) {
this.containingElement = this;
}
// Create the popover after render
this.updateComplete.then(() => {
this.popover = createPopper(this.trigger, this.positioner, {
placement: this.placement,
strategy: this.hoist ? 'fixed' : 'absolute',
modifiers: [
{
name: 'flip',
options: {
boundary: 'viewport'
}
},
{
name: 'offset',
options: {
offset: [this.skidding, this.distance]
}
}
]
});
});
}
firstUpdated() {
@@ -131,7 +109,6 @@ export default class SlDropdown extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
this.hide();
this.popover.destroy();
}
focusOnTrigger() {
@@ -207,40 +184,13 @@ export default class SlDropdown extends LitElement {
}
}
@watch('distance')
@watch('hoist')
@watch('placement')
@watch('skidding')
handlePopoverOptionsChange() {
if (this.popover) {
this.popover.setOptions({
placement: this.placement,
strategy: this.hoist ? 'fixed' : 'absolute',
modifiers: [
{
name: 'flip',
options: {
boundary: 'viewport'
}
},
{
name: 'offset',
options: {
offset: [this.skidding, this.distance]
}
}
]
});
}
}
handleTriggerClick() {
this.open ? this.hide() : this.show();
}
handleTriggerKeyDown(event: KeyboardEvent) {
const menu = this.getMenu();
const menuItems = menu ? ([...menu.querySelectorAll('sl-menu-item')] as SlMenuItem[]) : [];
const menuItems = menu ? menu.getAllItems() : [];
const firstMenuItem = menuItems[0];
const lastMenuItem = menuItems[menuItems.length - 1];
@@ -262,7 +212,7 @@ export default class SlDropdown extends LitElement {
// 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'].includes(event.key)) {
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
// Show the menu if it's not already open
@@ -271,14 +221,14 @@ export default class SlDropdown extends LitElement {
}
// Focus on a menu item
if (event.key === 'ArrowDown' && firstMenuItem) {
if (['ArrowDown', 'Home'].includes(event.key) && firstMenuItem) {
const menu = this.getMenu();
menu.setCurrentItem(firstMenuItem);
firstMenuItem.focus();
return;
}
if (event.key === 'ArrowUp' && lastMenuItem) {
if (['ArrowUp', 'End'].includes(event.key) && lastMenuItem) {
menu.setCurrentItem(lastMenuItem);
lastMenuItem.focus();
return;
@@ -327,7 +277,7 @@ export default class SlDropdown extends LitElement {
}
}
/** Shows the dropdown panel. */
/** Shows the dropdown panel */
async show() {
if (this.open) {
return;
@@ -352,11 +302,9 @@ export default class SlDropdown extends LitElement {
* is activated.
*/
reposition() {
if (!this.open) {
return;
if (this.popover) {
this.popover.update();
}
this.popover.update();
}
@watch('open', { waitUntilFirstUpdate: true })
@@ -376,7 +324,26 @@ export default class SlDropdown extends LitElement {
document.addEventListener('mousedown', this.handleDocumentMouseDown);
await stopAnimations(this);
this.popover.update();
this.popover = createPopper(this.trigger, this.positioner, {
placement: this.placement,
strategy: this.hoist ? 'fixed' : 'absolute',
modifiers: [
{
name: 'flip',
options: {
boundary: 'viewport'
}
},
{
name: 'offset',
options: {
offset: [this.skidding, this.distance]
}
}
]
});
this.panel.hidden = false;
const { keyframes, options } = getAnimation(this, 'dropdown.show');
await animateTo(this.panel, keyframes, options);

View File

@@ -11,6 +11,7 @@ export { default as SlButtonGroup } from './components/button-group/button-group
export { default as SlCard } from './components/card/card';
export { default as SlCheckbox } from './components/checkbox/checkbox';
export { default as SlColorPicker } from './components/color-picker/color-picker';
export { default as SlContextMenu } from './components/context-menu/context-menu';
export { default as SlDetails } from './components/details/details';
export { default as SlDialog } from './components/dialog/dialog';
export { default as SlDivider } from './components/divider/divider';