Compare commits

..

13 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
Cory LaViska
fbd6691711 improve search panel color 2021-10-29 14:42:17 -04:00
Cory LaViska
aec17da6b0 improve trigger border color in dark mode 2021-10-29 14:35:57 -04:00
Cory LaViska
639533662d update changelog 2021-10-26 09:35:59 -04:00
Cory LaViska
a340ce4a68 document part 2021-10-26 09:35:46 -04:00
Cory LaViska
6e5fe64e8b add eye dropper 2021-10-26 09:35:07 -04:00
Cory LaViska
84bdbb84b8 update lit 2021-10-26 09:34:48 -04:00
Cory LaViska
f91ffb6cb4 fix border radius on single button groups 2021-10-26 09:34:33 -04:00
Cory LaViska
13815199a3 fix dark theme link 2021-10-22 10:57:07 -04:00
17 changed files with 632 additions and 138 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

@@ -39,7 +39,7 @@ body.site-search-visible {
flex-direction: column;
max-width: 460px;
max-height: calc(100vh - 20rem);
background-color: rgb(var(--sl-color-neutral-0));
background-color: rgb(var(--sl-surface-base-alt));
border-radius: var(--sl-border-radius-large);
box-shadow: var(--sl-shadow-x-large);
margin: 10rem auto;

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

@@ -16,7 +16,7 @@ The easiest way to install Shoelace is with the CDN. Just add the following tags
If you prefer to use the dark theme instead, use this. Note the `sl-theme-dark` class on the `<html>` element. [Learn more about the Dark Theme.](/getting-started/themes#dark-theme)
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/light.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/dark.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
```

View File

@@ -6,6 +6,15 @@ Components with the <sl-badge type="warning" pill>Experimental</sl-badge> badge
_During the beta period, these restrictions may be relaxed in the event of a mission-critical bug._ 🐛
## 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
This version once again restores the bundled distribution because the unbundled + CDN approach is currently confusing and [not working properly](https://github.com/shoelace-style/shoelace/issues/559#issuecomment-949662331). Unbundling the few dependencies Shoelace has is still a goal of the project, but [this jsDelivr bug](https://github.com/jsdelivr/jsdelivr/issues/18337) needs to be resolved before we can achieve it.

32
package-lock.json generated
View File

@@ -12,7 +12,6 @@
"@popperjs/core": "^2.7.0",
"@shoelace-style/animations": "^1.1.0",
"color": "^3.1.3",
"lit": "^2.0.0",
"qr-creator": "^1.0.0"
},
"devDependencies": {
@@ -36,6 +35,7 @@
"get-port": "^5.1.1",
"globby": "^11.0.4",
"husky": "^4.3.8",
"lit": "^2.0.2",
"lunr": "^2.3.9",
"mkdirp": "^0.5.5",
"plop": "^2.7.4",
@@ -176,7 +176,8 @@
"node_modules/@lit/reactive-element": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.0.0.tgz",
"integrity": "sha512-Kpgenb8UNFsKCsFhggiVvUkCbcFQSd6N8hffYEEGjz27/4rw3cTSsmP9t3q1EHOAsdum60Wo64HvuZDFpEwexA=="
"integrity": "sha512-Kpgenb8UNFsKCsFhggiVvUkCbcFQSd6N8hffYEEGjz27/4rw3cTSsmP9t3q1EHOAsdum60Wo64HvuZDFpEwexA==",
"dev": true
},
"node_modules/@mdn/browser-compat-data": {
"version": "3.3.5",
@@ -801,7 +802,8 @@
"node_modules/@types/trusted-types": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
"integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg=="
"integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==",
"dev": true
},
"node_modules/@types/uuid": {
"version": "8.3.0",
@@ -6144,9 +6146,10 @@
"dev": true
},
"node_modules/lit": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.0.0.tgz",
"integrity": "sha512-pqi5O/wVzQ9Bn4ERRoYQlt1EAUWyY5Wv888vzpoArbtChc+zfUv1XohRqSdtQZYCogl0eHKd+MQwymg2XJfECg==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.0.2.tgz",
"integrity": "sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==",
"dev": true,
"dependencies": {
"@lit/reactive-element": "^1.0.0",
"lit-element": "^3.0.0",
@@ -6157,6 +6160,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.0.0.tgz",
"integrity": "sha512-oPqRhhBBhs+AlI62QLwtWQNU/bNK/h2L1jI3IDroqZubo6XVAkyNy2dW3CRfjij8mrNlY7wULOfyyKKOnfEePA==",
"dev": true,
"dependencies": {
"@lit/reactive-element": "^1.0.0",
"lit-html": "^2.0.0"
@@ -6166,6 +6170,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.0.0.tgz",
"integrity": "sha512-tJsCapCmc0vtLj6harqd6HfCxnlt/RSkgowtz4SC9dFE3nSL38Tb33I5HMDiyJsRjQZRTgpVsahrnDrR9wg27w==",
"dev": true,
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
@@ -11291,7 +11296,8 @@
"@lit/reactive-element": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.0.0.tgz",
"integrity": "sha512-Kpgenb8UNFsKCsFhggiVvUkCbcFQSd6N8hffYEEGjz27/4rw3cTSsmP9t3q1EHOAsdum60Wo64HvuZDFpEwexA=="
"integrity": "sha512-Kpgenb8UNFsKCsFhggiVvUkCbcFQSd6N8hffYEEGjz27/4rw3cTSsmP9t3q1EHOAsdum60Wo64HvuZDFpEwexA==",
"dev": true
},
"@mdn/browser-compat-data": {
"version": "3.3.5",
@@ -11899,7 +11905,8 @@
"@types/trusted-types": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
"integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg=="
"integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==",
"dev": true
},
"@types/uuid": {
"version": "8.3.0",
@@ -16166,9 +16173,10 @@
"dev": true
},
"lit": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.0.0.tgz",
"integrity": "sha512-pqi5O/wVzQ9Bn4ERRoYQlt1EAUWyY5Wv888vzpoArbtChc+zfUv1XohRqSdtQZYCogl0eHKd+MQwymg2XJfECg==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.0.2.tgz",
"integrity": "sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==",
"dev": true,
"requires": {
"@lit/reactive-element": "^1.0.0",
"lit-element": "^3.0.0",
@@ -16179,6 +16187,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.0.0.tgz",
"integrity": "sha512-oPqRhhBBhs+AlI62QLwtWQNU/bNK/h2L1jI3IDroqZubo6XVAkyNy2dW3CRfjij8mrNlY7wULOfyyKKOnfEePA==",
"dev": true,
"requires": {
"@lit/reactive-element": "^1.0.0",
"lit-html": "^2.0.0"
@@ -16188,6 +16197,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.0.0.tgz",
"integrity": "sha512-tJsCapCmc0vtLj6harqd6HfCxnlt/RSkgowtz4SC9dFE3nSL38Tb33I5HMDiyJsRjQZRTgpVsahrnDrR9wg27w==",
"dev": true,
"requires": {
"@types/trusted-types": "^2.0.2"
}

View File

@@ -42,7 +42,6 @@
"@popperjs/core": "^2.7.0",
"@shoelace-style/animations": "^1.1.0",
"color": "^3.1.3",
"lit": "^2.0.0",
"qr-creator": "^1.0.0"
},
"devDependencies": {
@@ -66,6 +65,7 @@
"get-port": "^5.1.1",
"globby": "^11.0.4",
"husky": "^4.3.8",
"lit": "^2.0.2",
"lunr": "^2.3.9",
"mkdirp": "^0.5.5",
"plop": "^2.7.4",

View File

@@ -598,7 +598,7 @@ export default css`
* buttons and we style them here instead.
*/
:host(.sl-button-group__button--first) .button {
:host(.sl-button-group__button--first:not(.sl-button-group__button--last)) .button {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
@@ -607,7 +607,7 @@ export default css`
border-radius: 0;
}
:host(.sl-button-group__button--last) .button {
:host(.sl-button-group__button--last:not(.sl-button-group__button--first)) .button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

View File

@@ -191,11 +191,14 @@ export default css`
flex: 1 1 auto;
}
.color-picker__user-input sl-button-group {
margin-left: var(--sl-spacing-small);
}
.color-picker__user-input sl-button {
min-width: 3.25rem;
max-width: 3.25rem;
font-size: 1rem;
margin-left: var(--sl-spacing-small);
}
.color-picker__swatches {
@@ -299,7 +302,7 @@ export default css`
height: 100%;
border-radius: inherit;
background-color: currentColor;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
box-shadow: inset 0 0 0 1px rgb(var(--sl-color-neutral-1000) / 25%);
transition: inherit;
}

View File

@@ -13,15 +13,19 @@ import color from 'color';
import styles from './color-picker.styles';
import '../button/button';
import '../button-group/button-group';
import '../dropdown/dropdown';
import '../icon/icon';
import '../input/input';
const hasEyeDropper = 'EyeDropper' in window;
/**
* @since 2.0
* @status stable
*
* @dependency sl-button
* @dependency sl-button-group
* @dependency sl-dropdown
* @dependency sl-input
*
@@ -39,6 +43,7 @@ import '../input/input';
* @csspart slider-handle - Hue and opacity slider handles.
* @csspart preview - The preview color.
* @csspart input - The text input.
* @csspart eye-dropper-button - The toggle format button's base.
* @csspart format-button - The toggle format button's base.
*
* @cssproperty --grid-width - The width of the color grid.
@@ -570,6 +575,22 @@ export default class SlColorPicker extends LitElement {
this.previewButton.classList.remove('color-picker__preview-color--copied');
}
handleEyeDropper() {
if (!hasEyeDropper) {
return;
}
// @ts-ignore
const eyeDropper = new EyeDropper();
eyeDropper
.open()
.then((colorSelectionResult: any) => this.setColor(colorSelectionResult.sRGBHex))
.catch(() => {
// The user canceled, do nothing
});
}
@watch('format')
handleFormatChange() {
this.syncValues();
@@ -606,6 +627,7 @@ export default class SlColorPicker extends LitElement {
const x = this.saturation;
const y = 100 - this.lightness;
// TODO - i18n for format, copy, and eye dropper buttons
const colorPicker = html`
<div
part="base"
@@ -708,6 +730,7 @@ export default class SlColorPicker extends LitElement {
type="button"
part="preview"
class="color-picker__preview color-picker__transparent-bg"
aria-label="Copy"
style=${styleMap({
'--preview-color': `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
})}
@@ -730,13 +753,26 @@ export default class SlColorPicker extends LitElement {
@sl-change=${this.handleInputChange}
></sl-input>
${!this.noFormatToggle
? html`
<sl-button exportparts="base:format-button" @click=${this.handleFormatToggle}>
${this.setLetterCase(this.format)}
</sl-button>
`
: ''}
<sl-button-group>
${!this.noFormatToggle
? html`
<sl-button
aria-label="Change format"
exportparts="base:format-button"
@click=${this.handleFormatToggle}
>
${this.setLetterCase(this.format)}
</sl-button>
`
: ''}
${hasEyeDropper
? html`
<sl-button exportparts="base:eye-dropper-button" @click=${this.handleEyeDropper}>
<sl-icon library="system" name="eyedropper" label="Select a color from the screen"></sl-icon>
</sl-button>
`
: ''}
</sl-button-group>
</div>
${this.swatches

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

@@ -41,6 +41,11 @@ const icons = {
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
</svg>
`,
eyedropper: `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eyedropper" viewBox="0 0 16 16">
<path d="M13.354.646a1.207 1.207 0 0 0-1.708 0L8.5 3.793l-.646-.647a.5.5 0 1 0-.708.708L8.293 5l-7.147 7.146A.5.5 0 0 0 1 12.5v1.793l-.854.853a.5.5 0 1 0 .708.707L1.707 15H3.5a.5.5 0 0 0 .354-.146L11 7.707l1.146 1.147a.5.5 0 0 0 .708-.708l-.647-.646 3.147-3.146a1.207 1.207 0 0 0 0-1.708l-2-2zM2 12.707l7-7L10.293 7l-7 7H2v-1.293z"></path>
</svg>
`,
'grip-vertical': `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-grip-vertical" viewBox="0 0 16 16">
<path d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>

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';