Compare commits

...

118 Commits

Author SHA1 Message Date
Cory LaViska
227ecc0193 2.0.0-beta.44 2021-06-17 17:40:01 -04:00
Cory LaViska
ee260e671f update version 2021-06-17 17:39:16 -04:00
Cory LaViska
1a954c5b25 convert build scripts to ESM 2021-06-17 17:38:48 -04:00
Cory LaViska
a14dd95c21 assign certain props as properties 2021-06-17 16:45:49 -04:00
Cory LaViska
19f7918435 remove comments 2021-06-17 16:44:53 -04:00
Cory LaViska
1e4de5f821 fixes #455 2021-06-16 08:42:02 -04:00
Cory LaViska
45f4b33eb1 add select sl-change test 2021-06-15 09:36:45 -04:00
Cory LaViska
16e7287c24 rework @watch decorator 2021-06-15 09:26:35 -04:00
Cory LaViska
3b2b5eed5a add test for sl-include 2021-06-15 09:11:04 -04:00
Cory LaViska
0521740824 fix preview imports 2021-06-14 17:17:41 -04:00
Cory LaViska
1d2033953b npm audit 2021-06-14 17:17:27 -04:00
Cory LaViska
e20edefc61 allow null 2021-06-10 09:50:30 -04:00
Cory LaViska
a9287d9d80 use a button 2021-06-10 09:34:13 -04:00
Cory LaViska
ba029db24e only open when key press is printable 2021-06-10 09:33:02 -04:00
Cory LaViska
529c187bc4 make imports consistent 2021-06-09 08:48:26 -04:00
Cory LaViska
714914ffe5 Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2021-06-09 08:36:27 -04:00
Matthias Max
cd23b9ebfe Fix import path (#463) 2021-06-09 08:36:19 -04:00
Cory LaViska
35ce68f4f6 fix animation import example 2021-06-09 08:34:31 -04:00
Cory LaViska
f7bcd89b97 fixes #456 2021-06-05 12:29:57 -04:00
Cory LaViska
0b44fba68c cleanup listener 2021-06-05 12:28:35 -04:00
Cory LaViska
501869c7aa fixes #458 2021-06-04 09:33:43 -04:00
Cory LaViska
15cb1cb746 fixes #457 2021-06-04 08:56:35 -04:00
Cory LaViska
7454cc12a1 restore select docs 2021-06-04 08:19:28 -04:00
Cory LaViska
52d52810b9 2.0.0-beta.43 2021-06-03 14:14:53 -04:00
Cory LaViska
936039f7a7 update changelog 2021-06-03 14:14:11 -04:00
Cory LaViska
ba0e8f7973 add link 2021-06-03 11:55:56 -04:00
Cory LaViska
8449a99418 update splash 2021-06-03 11:55:47 -04:00
Cory LaViska
28c9dbab1f add initial tests 2021-06-03 08:42:51 -04:00
Cory LaViska
81753cd44b fixes #454 2021-06-03 07:38:05 -04:00
Cory LaViska
9970bc84ff fixes #452; fixes #453 2021-06-02 19:06:04 -04:00
Cory LaViska
499bc4c4cd fix comments 2021-06-02 19:05:47 -04:00
Cory LaViska
b0921b5be0 fixes #451 2021-06-02 08:47:55 -04:00
Cory LaViska
afc4dfaf50 use globby 2021-06-02 07:41:28 -04:00
Cory LaViska
9dda3a9323 remove unused lib 2021-06-02 07:41:18 -04:00
Cory LaViska
115e80dce0 fix scrollable tabs 2021-06-01 08:21:10 -04:00
Cory LaViska
9f405686ec fixes #450 2021-06-01 08:09:13 -04:00
Cory LaViska
e7d7469c4e make reflected types show up in docs 2021-05-30 09:46:09 -04:00
Cory LaViska
0dbb72efe9 add scrollPosition method 2021-05-29 10:55:56 -04:00
Cory LaViska
9b21d5a619 add ? to optional args 2021-05-29 10:54:55 -04:00
Cory LaViska
8ffcdebffc 2.0.0-beta.42 2021-05-28 22:09:46 -04:00
Cory LaViska
e089184a14 revert 2021-05-28 22:09:40 -04:00
Cory LaViska
a2a059962c 2.0.0-beta.43 2021-05-28 22:08:45 -04:00
Cory LaViska
3eb42321d5 exclude tests 2021-05-28 22:08:21 -04:00
Cory LaViska
474484b059 2.0.0-beta.42 2021-05-28 22:03:24 -04:00
Cory LaViska
998e255636 update version 2021-05-28 22:02:27 -04:00
Cory LaViska
3938644442 add tests to prepub 2021-05-28 22:02:17 -04:00
Cory LaViska
b13e637593 added tests for details 2021-05-28 21:53:38 -04:00
Cory LaViska
95e3f5e0e8 fixes #445 2021-05-28 21:53:20 -04:00
Cory LaViska
d5ee79fe1e remove unused custom props 2021-05-28 21:08:31 -04:00
Cory LaViska
51f003d5fd add test info 2021-05-28 20:45:32 -04:00
Cory LaViska
dd89657f1e fix popper positioning 2021-05-28 10:47:24 -04:00
Cory LaViska
1e280608d3 update changelog 2021-05-28 10:14:34 -04:00
Cory LaViska
c6e5bedd3c fixes #448 2021-05-28 10:13:46 -04:00
Cory LaViska
0ff5b46799 add test runner and initial alert tests 2021-05-27 17:52:19 -04:00
Cory LaViska
e34090a87b update lit/esbuild 2021-05-27 17:00:43 -04:00
Cory LaViska
f690b24c68 use glob and ignore test files 2021-05-27 16:50:58 -04:00
Cory LaViska
10f045fe6e fix show/hide logic 2021-05-27 16:29:10 -04:00
Cory LaViska
8d8b77ca07 fix icon search and debounce results 2021-05-27 07:28:30 -04:00
Cory LaViska
234d2380ef update changelog 2021-05-26 20:28:25 -04:00
Cory LaViska
4de659d5bb Add iconoir example 2021-05-26 20:27:16 -04:00
Cory LaViska
2aabe4e11c 2.0.0-beta.41 2021-05-26 19:57:07 -04:00
Cory LaViska
171e55ce6d update version 2021-05-26 19:56:07 -04:00
Cory LaViska
297e6c8872 npm audit 2021-05-26 19:55:44 -04:00
Cory LaViska
2b39d613b7 update docs 2021-05-26 19:53:56 -04:00
Cory LaViska
2432fd1d85 remove bad keyframe 2021-05-26 12:55:12 -04:00
Cory LaViska
3cc3d4997b update changelog 2021-05-26 12:49:30 -04:00
Cory LaViska
9c0189f8be fix disabled and destroy popovers 2021-05-26 12:49:20 -04:00
Cory LaViska
fc5a21f57d add guard to show/hide methods 2021-05-26 12:43:30 -04:00
Cory LaViska
ee9ce8a87b remove popover util 2021-05-26 12:43:03 -04:00
Cory LaViska
d720121044 update tooltip 2021-05-26 12:42:50 -04:00
Cory LaViska
3fce846a8d add parseDuration method 2021-05-26 12:41:52 -04:00
Cory LaViska
8776c3f4a8 destructure animation 2021-05-26 08:42:55 -04:00
Cory LaViska
189ad7889d update dropdown 2021-05-26 08:42:33 -04:00
Cory LaViska
79a15e1470 remove unused code 2021-05-26 08:17:28 -04:00
Cory LaViska
dfd0d0ed30 Merge branch 'next' into animation-rework 2021-05-26 07:53:57 -04:00
Cory LaViska
d7bf0bd653 fixes #443 2021-05-26 07:51:57 -04:00
Cory LaViska
6044190019 update changelog 2021-05-26 07:33:38 -04:00
Cory LaViska
89b8d0ef67 update template 2021-05-26 07:33:00 -04:00
Cory LaViska
489d713fa2 update tab group placements 2021-05-26 07:32:51 -04:00
Cory LaViska
8d984d8dac only support Keyframe[] 2021-05-26 07:32:31 -04:00
Cory LaViska
cadbae85a5 update drawer 2021-05-26 07:32:16 -04:00
Cory LaViska
01bb476023 slow it down 2021-05-26 07:31:42 -04:00
Cory LaViska
d5c37f7b29 update details 2021-05-26 07:31:26 -04:00
Cory LaViska
99181cf5c6 slow it down 2021-05-26 07:29:59 -04:00
Cory LaViska
7836b8229a Merge branch 'next' into animation-rework 2021-05-24 16:09:16 -04:00
Cory LaViska
3cff627b22 Merge branch 'current' into next 2021-05-24 16:08:59 -04:00
Cory LaViska
4263899bc0 parse decorator for attribute 2021-05-24 16:07:41 -04:00
Cory LaViska
0f4bb2b24b fix comment 2021-05-24 16:07:06 -04:00
Cory LaViska
7bd7b421b8 update syntax 2021-05-24 09:15:09 -04:00
Cory LaViska
49eb9bbcf8 fix metadata 2021-05-24 09:13:46 -04:00
Cory LaViska
a87596b3a1 Add animation registry and update alert/dialog 2021-05-21 17:53:53 -04:00
Cory LaViska
1ca890b1e9 rework alert animations 2021-05-19 13:46:18 -04:00
Cory LaViska
52aba14ae9 add animation utils 2021-05-19 13:46:03 -04:00
Cory LaViska
916ee07265 move custom property tags to main comment block 2021-05-17 18:04:28 -04:00
Cory LaViska
1e67c7411c add settings and recommended extensions for vs code 2021-05-17 09:03:18 -04:00
Cory LaViska
62ef8e17c7 remove resize observer types
These were finally added to TypeScript so they're no longer needed.
2021-05-17 09:02:59 -04:00
Cory LaViska
327ef6b06c fix typo 2021-05-13 10:51:50 -04:00
Cory LaViska
5dff7b2855 remove md extension from docs 2021-05-13 09:11:24 -04:00
Cory LaViska
22b359a612 update typedoc 2021-05-13 09:10:52 -04:00
Cory LaViska
7bf3a8d8f7 2.0.0-beta.40 2021-05-12 08:04:59 -04:00
Cory LaViska
4bd73ac374 update version 2021-05-12 08:04:07 -04:00
Cory LaViska
cae50866f9 remove global [hidden] styles 2021-05-12 07:42:44 -04:00
Cory LaViska
ebd1b95ba0 remove src/utilities/index 2021-05-12 07:33:56 -04:00
Cory LaViska
703cb4dc7a update typescript
=
2021-05-12 07:30:26 -04:00
Cory LaViska
e274904d28 use platform agnostic delete 2021-05-11 08:35:31 -04:00
Cory LaViska
b3bcfc9934 update bootstrap icons to 1.5.0 2021-05-10 12:43:31 -04:00
Cory LaViska
a3beaafbcc fixes #424 2021-05-10 09:31:53 -04:00
Cory LaViska
5c619b87b6 add responsive media component; fixes #436 2021-05-04 09:51:16 -04:00
Cory LaViska
86fc6b85d6 fix import bug; closes #439 2021-05-03 15:08:17 -04:00
Cory LaViska
4c1e077833 fixes #425 2021-05-03 11:32:59 -04:00
Cory LaViska
c8e94ea098 remove bypass logic 2021-05-03 11:32:38 -04:00
Cory LaViska
cf38478cd5 improve a11y 2021-05-03 11:28:57 -04:00
Cory LaViska
e3ca914eac remove null 2021-05-03 11:25:20 -04:00
Cory LaViska
18a933bf6b update attribution 2021-04-26 15:48:17 -04:00
Cory LaViska
7554f47258 Update React to use @shoelace-style/react 2021-04-26 11:48:24 -04:00
Cory LaViska
e579330177 update changelog 2021-04-26 07:07:57 -04:00
Cory LaViska
360cfa43d8 formatting corrections 2021-04-26 07:07:08 -04:00
Corbin Crutchley
f66535f7d4 Update NextJS docs for beta 39 (#434) 2021-04-26 07:04:24 -04:00
128 changed files with 9445 additions and 1714 deletions

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"ms-vscode.vscode-typescript-tslint-plugin",
"esbenp.prettier-vscode",
"bierner.lit-html",
"bashmish.es6-string-css"
]
}

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

View File

@@ -1,74 +1,74 @@
- Getting Started
- [Overview](/)
- [Installation](/getting-started/installation.md)
- [Usage](/getting-started/usage.md)
- [Customizing](/getting-started/customizing.md)
- [Themes](/getting-started/themes.md)
- [Installation](/getting-started/installation)
- [Usage](/getting-started/usage)
- [Customizing](/getting-started/customizing)
- [Themes](/getting-started/themes)
- Resources
- [Community](/resources/community.md)
- [Contributing](/resources/contributing.md)
- [Changelog](/resources/changelog.md)
- [Community](/resources/community)
- [Contributing](/resources/contributing)
- [Changelog](/resources/changelog)
- Components
- [Alert](/components/alert.md)
- [Avatar](/components/avatar.md)
- [Badge](/components/badge.md)
- [Button](/components/button.md)
- [Button Group](/components/button-group.md)
- [Card](/components/card.md)
- [Checkbox](/components/checkbox.md)
- [Color Picker](/components/color-picker.md)
- [Details](/components/details.md)
- [Dialog](/components/dialog.md)
- [Drawer](/components/drawer.md)
- [Dropdown](/components/dropdown.md)
- [Form](/components/form.md)
- [Icon](/components/icon.md)
- [Icon Button](/components/icon-button.md)
- [Image Comparer](/components/image-comparer.md)
- [Input](/components/input.md)
- [Menu](/components/menu.md)
- [Menu Divider](/components/menu-divider.md)
- [Menu Item](/components/menu-item.md)
- [Menu Label](/components/menu-label.md)
- [Progress Bar](/components/progress-bar.md)
- [Progress Ring](/components/progress-ring.md)
- [Radio](/components/radio.md)
- [Radio Group](/components/radio-group.md)
- [Range](/components/range.md)
- [Rating](/components/rating.md)
- [Responsive Embed](/components/responsive-embed.md)
- [Select](/components/select.md)
- [Skeleton](/components/skeleton.md)
- [Spinner](/components/spinner.md)
- [Switch](/components/switch.md)
- [Tab Group](/components/tab-group.md)
- [Tab](/components/tab.md)
- [Tab Panel](/components/tab-panel.md)
- [Tag](/components/tag.md)
- [Textarea](/components/textarea.md)
- [Tooltip](/components/tooltip.md)
- [Alert](/components/alert)
- [Avatar](/components/avatar)
- [Badge](/components/badge)
- [Button](/components/button)
- [Button Group](/components/button-group)
- [Card](/components/card)
- [Checkbox](/components/checkbox)
- [Color Picker](/components/color-picker)
- [Details](/components/details)
- [Dialog](/components/dialog)
- [Drawer](/components/drawer)
- [Dropdown](/components/dropdown)
- [Form](/components/form)
- [Icon](/components/icon)
- [Icon Button](/components/icon-button)
- [Image Comparer](/components/image-comparer)
- [Input](/components/input)
- [Menu](/components/menu)
- [Menu Divider](/components/menu-divider)
- [Menu Item](/components/menu-item)
- [Menu Label](/components/menu-label)
- [Progress Bar](/components/progress-bar)
- [Progress Ring](/components/progress-ring)
- [Radio](/components/radio)
- [Radio Group](/components/radio-group)
- [Range](/components/range)
- [Rating](/components/rating)
- [Select](/components/select)
- [Skeleton](/components/skeleton)
- [Spinner](/components/spinner)
- [Switch](/components/switch)
- [Tab Group](/components/tab-group)
- [Tab](/components/tab)
- [Tab Panel](/components/tab-panel)
- [Tag](/components/tag)
- [Textarea](/components/textarea)
- [Tooltip](/components/tooltip)
- Utilities
- [Animation](/components/animation.md)
- [Format Bytes](/components/format-bytes.md)
- [Format Date](/components/format-date.md)
- [Format Number](/components/format-number.md)
- [Include](/components/include.md)
- [QR Code](/components/qr-code.md)
- [Relative Time](/components/relative-time.md)
- [Resize Observer](/components/resize-observer.md)
- [Animation](/components/animation)
- [Format Bytes](/components/format-bytes)
- [Format Date](/components/format-date)
- [Format Number](/components/format-number)
- [Include](/components/include)
- [QR Code](/components/qr-code)
- [Relative Time](/components/relative-time)
- [Resize Observer](/components/resize-observer)
- [Responsive Media](/components/responsive-media)
- Design Tokens
- [Typography](/tokens/typography.md)
- [Color](/tokens/color.md)
- [Spacing](/tokens/spacing.md)
- [Elevation](/tokens/elevation.md)
- [Border Radius](/tokens/border-radius.md)
- [Transition](/tokens/transition.md)
- [Z-index](/tokens/z-index.md)
- [Typography](/tokens/typography)
- [Color](/tokens/color)
- [Spacing](/tokens/spacing)
- [Elevation](/tokens/elevation)
- [Border Radius](/tokens/border-radius)
- [Transition](/tokens/transition)
- [Z-index](/tokens/z-index)
- Tutorials
- [Integrating with NextJS](/tutorials/integrating-with-nextjs.md)
- [Integrating with Rails](/tutorials/integrating-with-rails.md)
- [Integrating with NextJS](/tutorials/integrating-with-nextjs)
- [Integrating with Rails](/tutorials/integrating-with-rails)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 677 KiB

After

Width:  |  Height:  |  Size: 709 KiB

View File

@@ -51,7 +51,7 @@
${pre.outerHTML}
<div class="code-block__toggle" aria-expanded="false" aria-controls="${preId}">
<button type="button" class="code-block__toggle" aria-expanded="false" aria-controls="${preId}">
Source
<svg
viewBox="0 0 24 24"
@@ -63,7 +63,7 @@
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
</button>
</div>
`;

View File

@@ -1,10 +1,6 @@
(() => {
let metadataStore;
function getAttrName(propName) {
return propName.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
}
function createPropsTable(props) {
const table = document.createElement('table');
table.innerHTML = `
@@ -19,18 +15,17 @@
<tbody>
${props
.map(prop => {
const attr = getAttrName(prop.name);
return `
<tr>
<td>
<code>${escapeHtml(prop.name)}</code>
${
prop.name !== attr
prop.attribute && prop.name !== prop.attribute
? `
<br>
<small>
<sl-tooltip content="Use this attribute in your HTML">
<code class="attribute-tooltip">${escapeHtml(attr)}</code>
<sl-tooltip content="This is the corresponding HTML attribute">
<code class="attribute-tooltip">${escapeHtml(prop.attribute)}</code>
</sl-tooltip>
</small>`
: ''
@@ -99,7 +94,9 @@
method.params.length
? `
<code style="white-space: normal;">${escapeHtml(
method.params.map(param => `${param.name}: ${param.type}`).join(', ')
method.params
.map(param => `${param.name}${param.isOptional ? '?' : ''}: ${param.type}`)
.join(', ')
)}</code>
`
: ''
@@ -193,6 +190,32 @@
return table.outerHTML;
}
function createAnimationsTable(animations) {
const table = document.createElement('table');
table.innerHTML = `
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
${animations
.map(
animation => `
<tr>
<td><code>${escapeHtml(animation.name)}</code></td>
<td>${escapeHtml(animation.description)}</td>
</tr>
`
)
.join('')}
</tbody>
`;
return table.outerHTML;
}
function createDependenciesList(targetComponent, allComponents) {
const ul = document.createElement('ul');
const dependencies = [];
@@ -295,7 +318,7 @@
if (!component) {
console.error('Component not found in metadata: ' + tag);
next(content);
return next(content);
}
let badgeType = 'info';
@@ -332,7 +355,7 @@
if (!component) {
console.error('Component not found in metadata: ' + tag);
next(content);
return next(content);
}
if (component.props.length) {
@@ -377,6 +400,15 @@
`;
}
if (component.animations.length) {
result += `
## Animations
${createAnimationsTable(component.animations)}
Learn how to [customize animations](/getting-started/customizing#animations).
`;
}
if (component.dependencies.length) {
result += `
## Dependencies

View File

@@ -47,7 +47,7 @@ This example demonstrates all of the baked-in animations and easings. Animations
</div>
<script type="module">
import { getAnimationNames, getEasingNames } from '/dist/shoelace.js';
import { getAnimationNames, getEasingNames } from '/dist/utilities/animation.js';
const container = document.querySelector('.animation-sandbox');
const animation = container.querySelector('sl-animation');

View File

@@ -8,7 +8,7 @@ Checkboxes allow the user to toggle an option on or off.
<sl-checkbox>Checkbox</sl-checkbox>
```
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form.md) instead.
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form) instead.
## Examples

View File

@@ -24,20 +24,20 @@ Drawers slide in from a container to expose additional options and information.
## Examples
### Slide in From Left
### Slide in From Start
To make the drawer slide in from the left, set the `placement` attribute to `left`.
By default, drawers slide in from the end. To make the drawer slide in from the start, set the `placement` attribute to `start`.
```html preview
<sl-drawer label="Drawer" placement="left" class="drawer-placement-left">
This drawer slides in from the left.
<sl-drawer label="Drawer" placement="start" class="drawer-placement-start">
This drawer slides in from the start.
<sl-button slot="footer" type="primary">Close</sl-button>
</sl-drawer>
<sl-button>Open Drawer</sl-button>
<script>
const drawer = document.querySelector('.drawer-placement-left');
const drawer = document.querySelector('.drawer-placement-start');
const openButton = drawer.nextElementSibling;
const closeButton = drawer.querySelector('sl-button[type="primary"]');

View File

@@ -6,7 +6,7 @@ Dropdowns expose additional content that "drops down" in a panel.
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](/components/menu.md) 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](/components/color-picker.md) and [select](/components/select.md)). The API gives you complete control over showing, hiding, and positioning the panel.
Dropdowns are designed to work well with [menus](/components/menu) 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](/components/color-picker) and [select](/components/select)). The API gives you complete control over showing, hiding, and positioning the panel.
```html preview
<sl-dropdown>
@@ -123,7 +123,7 @@ Dropdown panels will be clipped if they're inside a container that has `overflow
### Getting the Selected Item
When dropdowns are used with [menus](/components/menu.md), 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.
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">

View File

@@ -75,8 +75,6 @@ When a form control is invalid, the containing form will not be submitted. Inste
All form controls support validation, but not all validation props are available for every component. Refer to a component's documentation to see which validation props it supports.
Note that validity is not checked until the user interacts with the control or its containing form is submitted. This prevents required controls from being rendered as invalid right away, which can result in a poor user experience. If you need this behavior, set the `invalid` attribute initially.
!> Client-side validation can be used to improve the UX of forms, but it is not a replacement for server-side validation. **You should always validate and sanitize user input on the server!**
### Required Fields

View File

@@ -76,7 +76,7 @@ Here's an example that registers an icon library located in the `/assets/icons`
```html
<script type="module">
import { registerIconLibrary } from '/shoelace/dist/utilities/icon-library.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('my-icons', {
resolver: name => `/assets/icons/${name}.svg`,
@@ -104,7 +104,7 @@ Icons in this library are licensed under the [Creative Commons 4.0 License](http
```html preview
<script type="module">
import { registerIconLibrary } from '/dist/shoelace.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('boxicons', {
resolver: name => {
@@ -158,7 +158,7 @@ Icons in this library are licensed under the [MIT License](https://github.com/fe
</div>
<script type="module">
import { registerIconLibrary } from '/dist/shoelace.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('feather', {
resolver: name => `https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/icons/${name}.svg`
@@ -174,7 +174,7 @@ Icons in this library are licensed under the [Font Awesome Free License](https:/
```html preview
<script type="module">
import { registerIconLibrary } from '/dist/shoelace.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('fa', {
resolver: name => {
@@ -220,7 +220,7 @@ Icons in this library are licensed under the [MIT License](https://github.com/ta
```html preview
<script type="module">
import { registerIconLibrary } from '/dist/shoelace.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('heroicons', {
resolver: name => `https://cdn.jsdelivr.net/npm/heroicons@0.4.2/outline/${name}.svg`
@@ -237,6 +237,31 @@ Icons in this library are licensed under the [MIT License](https://github.com/ta
</div>
```
### Iconoir
This will register the [Iconoir](https://iconoir.com/) library using the jsDelivr CDN.
Icons in this library are licensed under the [MIT License](https://github.com/lucaburgio/iconoir/blob/master/LICENSE).
```html preview
<script type="module">
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('iconoir', {
resolver: name => `https://cdn.jsdelivr.net/gh/lucaburgio/iconoir@latest/icons/${name}.svg`
});
</script>
<div style="font-size: 24px;">
<sl-icon library="iconoir" name="check-circled-outline"></sl-icon>
<sl-icon library="iconoir" name="drawer"></sl-icon>
<sl-icon library="iconoir" name="keyframes"></sl-icon>
<sl-icon library="iconoir" name="headset-help"></sl-icon>
<sl-icon library="iconoir" name="color-picker"></sl-icon>
<sl-icon library="iconoir" name="wifi"></sl-icon>
</div>
```
### Ionicons
This will register the [Ionicons](https://ionicons.com/) library using the jsDelivr CDN. This library has three variations: outline (default), filled (`*-filled`), and sharp (`*-sharp`). A mutator function is required to polyfill a handful of styles we're not including.
@@ -245,11 +270,11 @@ Icons in this library are licensed under the [MIT License](https://github.com/io
```html preview
<script type="module">
import { registerIconLibrary } from '/dist/shoelace.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('ionicons', {
resolver: name => `https://cdn.jsdelivr.net/npm/ionicons@5.1.2/dist/ionicons/svg/${name}.svg`,
mutator: svg => {
mutator: svg => {
svg.setAttribute('fill', 'currentColor');
svg.setAttribute('stroke', 'currentColor');
[...svg.querySelectorAll('.ionicon-fill-none')].map(el => el.setAttribute('fill', 'none'));
@@ -290,7 +315,7 @@ Icons in this library are licensed under the [MIT License](https://github.com/mi
```html preview
<script type="module">
import { registerIconLibrary } from '/dist/shoelace.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('jam', {
resolver: name => `https://cdn.jsdelivr.net/npm/jam-icons@2.0.0/svg/${name}.svg`,
@@ -323,7 +348,7 @@ Icons in this library are licensed under the [Apache 2.0 License](https://github
```html preview
<script type="module">
import { registerIconLibrary } from '/dist/shoelace.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('material', {
resolver: name => {
@@ -366,7 +391,7 @@ Icons in this library are licensed under the [Apache 2.0 License](https://github
```html preview
<script type="module">
import { registerIconLibrary } from '/dist/shoelace.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('remixicon', {
resolver: name => {
@@ -403,7 +428,7 @@ Icons in this library are licensed under the [Apache 2.0 License](https://github
```html preview
<script type="module">
import { registerIconLibrary } from '/dist/shoelace.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('unicons', {
resolver: name => {
@@ -439,7 +464,7 @@ This example will load the same set of icons from the jsDelivr CDN instead of yo
```html
<script type="module">
import { registerIconLibrary } from '/shoelace/dist/utilities/icon-library.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('default', {
resolver: name => `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.0.0/icons/${name}.svg`
@@ -455,7 +480,7 @@ If you want to change the icons Shoelace uses internally, you can register an ic
```html
<script type="module">
import { registerIconLibrary } from '/shoelace/dist/utilities/icon-library.js';
import { registerIconLibrary } from '/dist/utilities/icon-library.js';
registerIconLibrary('system', {
resolver: name => `/path/to/custom/icons/${name}.svg`
@@ -475,6 +500,7 @@ If you want to change the icons Shoelace uses internally, you can register an ic
const loader = container.querySelector('.icon-loader');
const list = container.querySelector('.icon-list');
const queue = [];
let inputTimeout;
// Generate icons
icons.map(i => {
@@ -505,15 +531,18 @@ If you want to change the icons Shoelace uses internally, you can register an ic
// Filter as the user types
input.addEventListener('sl-input', () => {
[...list.querySelectorAll('.icon-list-item')].map(item => {
const filter = input.value.toLowerCase();
if (filter === '') {
item.hidden = false;
} else {
const terms = item.getAttribute('data-terms').toLowerCase();
item.hidden = terms.indexOf(filter) < 0;
}
});
clearTimeout(inputTimeout);
inputTimeout = setTimeout(() => {
[...list.querySelectorAll('.icon-list-item')].map(item => {
const filter = input.value.toLowerCase();
if (filter === '') {
item.hidden = false;
} else {
const terms = item.getAttribute('data-terms').toLowerCase();
item.hidden = terms.indexOf(filter) < 0;
}
});
}, 250);
});
// Sort by type and remember preference
@@ -534,6 +563,10 @@ If you want to change the icons Shoelace uses internally, you can register an ic
padding: var(--sl-spacing-medium);
}
.icon-search [hidden] {
display: none;
}
.icon-search-controls {
display: flex;
}

View File

@@ -8,7 +8,7 @@ Inputs collect data from the user.
<sl-input></sl-input>
```
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form.md) instead.
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form) instead.
?> Please refer to the section on [form control validation](/components/form?id=form-control-validation) to learn how to do client-side validation.

View File

@@ -4,7 +4,7 @@
Menus provide a list of options for the user to choose from.
You can use [menu items](/components/menu-item.md), [menu dividers](/components/menu-divider.md), and [menu labels](/components/menu-label.md) to compose a menu.
You can use [menu items](/components/menu-item), [menu dividers](/components/menu-divider), and [menu labels](/components/menu-label) to compose a menu.
```html preview
<sl-menu style="max-width: 200px; border: solid 1px var(--sl-panel-border-color); border-radius: var(--sl-border-radius-medium);">

View File

@@ -14,7 +14,7 @@ Radios are designed to be used with [radio groups](/components/radio-group). As
</sl-radio-group>
```
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form.md) instead.
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form) instead.
## Examples

View File

@@ -8,7 +8,7 @@ Ranges allow the user to select a single value within a given range using a slid
<sl-range min="0" max="100" step="1"></sl-range>
```
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form.md) instead.
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form) instead.
## Examples

View File

@@ -1,27 +0,0 @@
# Responsive Embed
[component-header:sl-responsive-embed]
Displays embedded media in a responsive manner based on its aspect ratio.
You can embed any element of the `<iframe>`, `<embed>`, or `<object>` type. The default aspect ratio is `16:9`.
```html preview
<sl-responsive-embed>
<iframe src="https://player.vimeo.com/video/1053647?title=0&byline=0&portrait=0" frameborder="0" allow="autoplay; fullscreen" allowfullscreen></iframe>
</sl-responsive-embed>
```
## Examples
### Aspect Ratio
To set the aspect ratio, use the `aspect-ratio` attribute.
```html preview
<sl-responsive-embed aspect-ratio="4:3">
<iframe src="https://www.youtube.com/embed/mM5_T-F1Yn4" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</sl-responsive-embed>
```
[component-metadata:sl-responsive-embed]

View File

@@ -0,0 +1,37 @@
# Responsive Media
[component-header:sl-responsive-media]
Displays media in the desired aspect ratio.
You can slot in any [replaced element](https://developer.mozilla.org/en-US/docs/Web/CSS/Replaced_element), including `<iframe>`, `<img>`, and `<video>`. As the element's width changes, its height will resize proportionally. Only one element should be slotted into the container. The default aspect ratio is `16:9`.
```html preview
<sl-responsive-media>
<img src="https://images.unsplash.com/photo-1541427468627-a89a96e5ca1d?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1800&q=80" alt="A train riding through autumn foliage with mountains in the distance.">
</sl-responsive-media>
```
## Examples
### Responsive Images
The following image maintains a `4:3` aspect ratio as its container is resized.
```html preview
<sl-responsive-media aspect-ratio="4:3">
<img src="https://images.unsplash.com/photo-1473186578172-c141e6798cf4?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1800&q=80" alt="Two blue chairs on a sandy beach.">
</sl-responsive-media>
```
### Responsive Videos
The following video is embedded using an `iframe` and maintains a `16:9` aspect ratio as its container is resized.
```html preview
<sl-responsive-media aspect-ratio="16:9">
<iframe src="https://player.vimeo.com/video/1053647?title=0&byline=0&portrait=0" frameborder="0" allow="autoplay; fullscreen" allowfullscreen></iframe>
</sl-responsive-media>
```
[component-metadata:sl-responsive-media]

View File

@@ -16,7 +16,7 @@ Selects allow you to choose one or more items from a dropdown menu.
</sl-select>
```
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form.md) instead.
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form) instead.
## Examples

View File

@@ -8,7 +8,7 @@ Switches allow the user to toggle an option on or off.
<sl-switch>Switch</sl-switch>
```
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form.md) instead.
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form) instead.
## Examples

View File

@@ -4,7 +4,7 @@
Tab groups organize content into a container that shows one section at a time.
Tab groups make use of [tabs](/components/tab.md) and [tab panels](/components/tab-panel.md). Each tab must be slotted into the `nav` slot and its `panel` must refer to a tab panel of the same name.
Tab groups make use of [tabs](/components/tab) and [tab panels](/components/tab-panel). Each tab must be slotted into the `nav` slot and its `panel` must refer to a tab panel of the same name.
```html preview
<sl-tab-group>
@@ -40,12 +40,12 @@ Tabs can be shown on the bottom by setting `placement` to `bottom`.
</sl-tab-group>
```
### Tabs on Left
### Tabs on Start
Tabs can be shown on the left by setting `placement` to `left`.
Tabs can be shown on the starting side by setting `placement` to `start`.
```html preview
<sl-tab-group placement="left">
<sl-tab-group placement="start">
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab slot="nav" panel="custom">Custom</sl-tab>
<sl-tab slot="nav" panel="advanced">Advanced</sl-tab>
@@ -58,12 +58,12 @@ Tabs can be shown on the left by setting `placement` to `left`.
</sl-tab-group>
```
### Tabs on Right
### Tabs on End
Tabs can be shown on the right by setting `placement` to `right`.
Tabs can be shown on the ending side by setting `placement` to `end`.
```html preview
<sl-tab-group placement="right">
<sl-tab-group placement="end">
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab slot="nav" panel="custom">Custom</sl-tab>
<sl-tab slot="nav" panel="advanced">Advanced</sl-tab>

View File

@@ -18,6 +18,6 @@ Tab panels are used inside tab groups to display content.
</sl-tab-group>
```
?> Additional demonstrations can be found in the [tab group examples](/components/tab-group.md).
?> Additional demonstrations can be found in the [tab group examples](/components/tab-group).
[component-metadata:sl-tab-panel]

View File

@@ -11,6 +11,6 @@ Tabs are used inside tab groups to represent tab panels.
<sl-tab disabled>Disabled</sl-tab>
```
?> Additional demonstrations can be found in the [tab group examples](/components/tab-group.md).
?> Additional demonstrations can be found in the [tab group examples](/components/tab-group).
[component-metadata:sl-tab]

View File

@@ -8,7 +8,7 @@ Textareas collect data from the user and allow multiple lines of text.
<sl-textarea></sl-textarea>
```
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form.md) instead.
?> This component doesn't work with standard forms. Use [`<sl-form>`](/components/form) instead.
?> Please refer to the section on [form control validation](/components/form?id=form-control-validation) to learn how to do client-side validation.

View File

@@ -106,3 +106,51 @@ Alternatively, you can set them inline directly on the element.
```
Not all components expose CSS custom properties. For those that do, they can be found in the component's API documentation.
## Animations
Some components use animation, such as when a dialog is shown or hidden. Animations are performed using the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) rather than CSS. However, you can still customize them through Shoelace's animation registry. If a component has customizable animations, they'll be listed in the "Animation" section of its documentation.
To customize a default animation, use the `setDefaultAnimation()` method. The function accepts an animation name (found in the component's docs) and an object with `keyframes` and `options` or `null` to disable the animation.
This example will make all dialogs use a custom show animation.
```js
import { setDefaultAnimation } from '@shoelace-style/shoelace/dist/utilities/animation-registry.js';
// Change the default animation for all dialogs
setDefaultAnimation('dialog.show', {
keyframes: [
{ transform: 'rotate(-10deg) scale(0.5)', opacity: '0' },
{ transform: 'rotate(0deg) scale(1)', opacity: '1' }
],
options: {
duration: 500
}
});
```
If you only want to target a single component, use the `setAnimation()` method instead. This function accepts an element, an animation name, and an object comprised of animation `keyframes` and `options`.
In this example, only the target dialog will use a custom show animation.
```js
import { setAnimation } from '@shoelace-style/shoelace/dist/utilities/animation-registry.js';
// Change the animation for a single dialog
const dialog = document.querySelector('#my-dialog');
setAnimation(dialog, 'dialog.show', {
keyframes: [
{ transform: 'rotate(-10deg) scale(0.5)', opacity: '0' },
{ transform: 'rotate(0deg) scale(1)', opacity: '1' }
],
options: {
duration: 500
}
});
```
To learn more about creating Web Animations, refer to the documentation for [`Element.animate()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/animate).
?> Animations respect the users `prefers-reduced-motion` setting. When this setting is enabled, animations will not be played. To disable animations for all users, set `options.duration` to `0`.

View File

@@ -11,7 +11,7 @@ The easiest way to install Shoelace is with the CDN. Just add the following tags
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace.js"></script>
```
Now you can [start using Shoelace!](/getting-started/usage.md)
Now you can [start using Shoelace!](/getting-started/usage)
## Local Installation
@@ -30,6 +30,8 @@ Once you've done that, add the following tags to your page. Make sure to update
<script type="module" src="/scripts/shoelace/dist/shoelace.js"></script>
```
?> For clarity, the docs will usually show imports from `@shoelace-style/shoelace`. If you're not using a module resolver or bundler, you'll need to adjust these paths to point to the folder Shoelace is in.
## Setting the Base Path
Some components rely on assets (icons, images, etc.) and Shoelace needs to know where they're located. For convenience, Shoelace will try to auto-detect the correct location based on the script you've loaded it from. This assumes assets are colocated with `shoelace.js` and will "just work" for most users.
@@ -56,15 +58,15 @@ The previous approach is the _easiest_ way to load Shoelace, but easy isn't alwa
Cherry picking can be done from your local install or [directly from the CDN](https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/). This will limit the number of files the browser has to download and reduce the amount of bytes being transferred. The disadvantage is that you need to load and register each component manually, including its dependencies.
Here's an example that loads only the button component and its dependencies. Again, we're assuming you're serving Shoelace's `dist` directory from `/scripts/shoelace`.
Here's an example that loads only the button component and its dependencies. Again, if you're not using a module resolver, you'll need to adjust the path to point to the folder Shoelace is in.
```html
<!-- The base stylesheet is always required -->
<link rel="stylesheet" href="/scripts/shoelace/dist/themes/base.css">
<link rel="stylesheet" href="@shoelace-style/shoelace/dist/themes/base.css">
<script type="module" data-shoelace="/scripts/shoelace">
import SlButton from '/scripts/shoelace/dist/components/button/button.js';
import SlSpinner from '/scripts/shoelace/dist/components/spinner/spinner.js';
import SlButton from '@shoelace-style/shoelace/dist/components/button/button.js';
import SlSpinner from '@shoelace-style/shoelace/dist/components/spinner/spinner.js';
// <sl-button> and <sl-spinner> are ready to use!
</script>

View File

@@ -1,20 +1,22 @@
<div class="splash">
<div class="splash-start">
<img class="splash-logo" src="/assets/images/wordmark.svg" alt="Shoelace">
<p><strong>A forward-thinking library of web components.</strong></p>
<ul>
<li>Works with all frameworks 🧩</li>
<li>Works with CDNs 🚛</li>
<li>Fully customizable with CSS 🎨</li>
<li>Includes an official dark theme 🌛</li>
<li>Built with accessibility in mind ♿️</li>
<li>Open source 😸</li>
</ul>
<p>Designed in New Hampshire by <a href="https://twitter.com/claviska" rel="noopener" target="_blank">Cory LaViska</a>.</p>
</div>
<div class="splash-end">
<img class="splash-image" src="/assets/images/undraw-content-team.svg" alt="Cartoon of people assembling components while standing on a giant laptop.">
</div>
<div class="splash-start">
<img class="splash-logo" src="/assets/images/wordmark.svg" alt="Shoelace">
**A forward-thinking library of web components.**
- Works with all frameworks 🧩
- Works with CDNs 🚛
- Fully customizable with CSS 🎨
- Includes an official dark theme 🌛
- Built with accessibility in mind ♿️
- First-party [React wrappers](/getting-started/usage#react)
- Open source 😸
Designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska).
</div>
<div class="splash-end">
<img class="splash-image" src="/assets/images/undraw-content-team.svg" alt="Cartoon of people assembling components while standing on a giant laptop.">
</div>
</div>
[![jsDelivr](https://data.jsdelivr.com/v1/package/npm/@shoelace-style/shoelace/badge?style=rounded)](https://www.jsdelivr.com/package/npm/@shoelace-style/shoelace)
@@ -39,7 +41,7 @@ Now you have access to all of Shoelace's components! Try adding a button:
<sl-button>Click me</sl-button>
```
See the [installation instructions](getting-started/installation.md) for more details and other ways to install Shoelace.
See the [installation instructions](getting-started/installation) for more details and other ways to install Shoelace.
## New to Web Components?
@@ -120,5 +122,6 @@ Special thanks to the following projects and individuals that helped make Shoela
- Icons are courtesy of [Bootstrap Icons](https://icons.getbootstrap.com/)
- The homepage illustration is courtesy of [unDraw](https://undraw.co/)
- Positioning of menus, tooltips, et al is handled by [Popper.js](https://popper.js.org/)
- The animation component was inspired by [animatable-component](https://github.com/proyecto26/animatable-component)
- Animations are courtesy of [animate.css](https://animate.style/)
- QR codes are generated with [qr-creator](https://github.com/nimiq/qr-creator)
- The Shoelace logo was designed with a single shoelace by [Adam K Olson](https://twitter.com/adamkolson)

View File

@@ -2,7 +2,7 @@
Shoelace ships with a dark theme that complements the default light theme. You can even take things a step further by designing your own custom theme.
The default theme is included as part of `themse/base.css` and should always be loaded first, even if you want to use another theme exclusively. The default theme contains important base tokens and utilities that many components rely on.
The default theme is included as part of `themes/base.css` and should always be loaded first, even if you want to use another theme exclusively. The default theme contains important base tokens and utilities that many components rely on.
## Dark Mode

View File

@@ -112,7 +112,7 @@ For example, `<button>` and `<sl-button>` both have a `type` attribute, but it d
Shoelace ships with a file called `vscode.html-custom-data.json` that can be used to describe its components to Visual Studio Code. This enables code completion for Shoelace components (also known as "code hinting" or "IntelliSense"). To enable it, you need to tell VS Code where the file is.
1. [Install Shoelace locally](/getting-started/installation.md#local-installation)
1. [Install Shoelace locally](/getting-started/installation#local-installation)
2. Create a folder called `.vscode` at the root of your project
3. Create a file inside the folder called `settings.json`
4. Add the following to the file
@@ -139,39 +139,51 @@ Event handling can also be cumbersome.
> Because React implements its own synthetic event system, it cannot listen for DOM events coming from Custom Elements without the use of a workaround. Developers will need to reference their Custom Elements using a ref and manually attach event listeners with addEventListener. This makes working with Custom Elements cumbersome.
Fortunately, there's a utility that will wrap Shoelace components so you can use them as if they were React components. 👇
?> If you're starting a new project, consider using [Preact](https://preactjs.com/) as an alternative. It shares the same API as React and [handles custom elements quite well](https://custom-elements-everywhere.com/#preact).
### Wrapping Components
You can use [this utility](https://www.npmjs.com/package/@shoelace-style/react-wrapper) to wrap Shoelace components so they work like regular React components. To install it, use this command.
Fortunately, there's a package called [@shoelace-style/react](https://www.npmjs.com/package/@shoelace-style/react) that will let you use Shoelace components as if they were React components. You can install it using this command.
```bash
npm install @shoelace-style/react-wrapper
npm install @shoelace-style/react
```
Now you can "import" Shoelace components as React components! Remember to [install Shoelace](/getting-started/installation.md) first, otherwise this won't work.
```js
import wrapCustomElement from '@shoelace-style/react-wrapper';
const SlButton = wrapCustomElement('sl-button');
return <SlButton type="primary">Click me</SlButton>;
```
A reference ("ref") to the underlying custom element is exposed through the `element` property so you can access it directly. This is useful for calling methods.
Include the base theme and any components you want to use in your app.
```jsx
<SlButton
ref={el => this.button = el}
onClick={() => this.button.element.current.blur()}
>
Click me
</SlButton>
import '@shoelace-style/shoelace/dist/themes/base.css';
import SlButton from '@shoelace-style/react/dist/button';
import SlSpinner from '@shoelace-style/react/dist/spinner';
// ...
const MyComponent = (props) => {
return (
<SlButton type="primary">
Click me
</SlButton>
)
};
```
### Dependencies
Some components depend on other components internally. For example, `<sl-button>` requires you to load `<sl-spinner>` because it's used internally for its loading state. If a component has dependencies, they'll be listed in the "Dependencies" section of its documentation. These are always Shoelace components, not third-party libraries.
Since dependencies are just components, you can load them the same way.
```jsx
import SlButton from '@shoelace-style/react/dist/button';
import SlSpinner from '@shoelace-style/react/dist/spinner';
```
However, this may cause your linter to complain (e.g. "SlButton is defined but never used"). If you're not going to use the dependent components in your JSX, you can import them as side effects instead.
```jsx
import '@shoelace-style/react/dist/button';
import '@shoelace-style/react/dist/spinner';
```
This extra step is required for dependencies to ensure they get registered with the browser as custom elements.
## Vue
Vue [plays nice](https://custom-elements-everywhere.com/#vue) with custom elements. You just have to tell it to ignore Shoelace components. This is pretty easy because they all start with `sl-`.
@@ -202,7 +214,7 @@ One caveat is there's currently [no support for v-model on custom elements](http
<sl-input :value="name" @input="name = $event.target.value">
```
If that's too verbose, you can use a custom directive instead. 👇
If that's too verbose, you can use a custom directive instead.
### Using a Custom Directive

View File

@@ -6,12 +6,92 @@ 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._ 🐛
## 2.0.0-beta.44
- 🚨 BREAKING: all `invalid` props on form controls now reflect validity before interaction [#455](https://github.com/shoelace-style/shoelace/issues/455)
- Allow `null` to be passed to disable animations in `setDefaultAnimation()` and `setAnimation()`
- Converted build scripts to ESM
- Fixed a bug in `sl-checkbox` where `invalid` did not update properly
- Fixed a bug in `sl-dropdown` where a `keydown` listener wasn't cleaned up properly
- Fixed a bug in `sl-select` where `sl-blur` was emitted prematurely [#456](https://github.com/shoelace-style/shoelace/issues/456)
- Fixed a bug in `sl-select` where no selection with `multiple` resulted in an incorrect value [#457](https://github.com/shoelace-style/shoelace/issues/457)
- Fixed a bug in `sl-select` where `sl-change` was emitted immediately after connecting to the DOM [#458](https://github.com/shoelace-style/shoelace/issues/458)
- Fixed a bug in `sl-select` where non-printable keys would cause the menu to open
- Fixed a bug in `sl-select` where `invalid` was not always updated properly
- Reworked the `@watch` decorator to use `update` instead of `updated` resulting in better performance and flexibility
## 2.0.0-beta.43
- Added `?` to optional arguments in methods tables in the docs
- Added the `scrollPosition()` method to `sl-textarea` to get/set scroll position
- Added intial tests for `sl-dialog`, `sl-drawer`, `sl-dropdown`, and `sl-tooltip`
- Fixed a bug in `sl-tab-group` where scrollable tab icons were not displaying correctly
- Fixed a bug in `sl-dialog` and `sl-drawer` where preventing clicks on the overlay no longer worked as described [#452](https://github.com/shoelace-style/shoelace/issues/452)
- Fixed a bug in `sl-dialog` and `sl-drawer` where setting initial focus no longer worked as described [#453](https://github.com/shoelace-style/shoelace/issues/453)
- Fixed a bug in `sl-card` where the `slotchange` listener wasn't attached correctly [#454](https://github.com/shoelace-style/shoelace/issues/454)
- Fixed lifecycle bugs in a number of components [#451](https://github.com/shoelace-style/shoelace/issues/451)
- Removed `fill: both` from internal animate utility so styles won't "stick" by default [#450](https://github.com/shoelace-style/shoelace/issues/450)
## 2.0.0-beta.42
This release addresses an issue with the `open` prop no longer working in a number of components, as a result of the changes in beta.41. It also removes a small but controversial feature that complicated show/hide logic and led to a poor experience for developers and end users.
There are two ways to show/hide affected components: by calling `show() | hide()` and by toggling the `open` prop. Previously, it was possible to call `event.preventDefault()` in an `sl-show | sl-hide ` handler to stop the component from showing/hiding. The problem becomes obvious when you set `el.open = false`, the event gets canceled, and in the next cycle `el.open` has reverted to `true`. Not only is this unexpected, but it also doesn't play nicely with frameworks. Additionally, this made it impossible to await `show() | hide()` since there was a chance they'd never resolve.
Technical reasons aside, canceling these events seldom led to a good user experience, so the decision was made to no longer allow `sl-show | sl-hide` to be cancelable.
- 🚨 BREAKING: `sl-show` and `sl-hide` events are no longer cancelable
- Added Iconoir example to the icon docs
- Added Web Test Runner
- Added intial tests for `sl-alert` and `sl-details`
- Changed the `cancelable` default to `false` for the internal `@event` decorator
- Fixed a bug where toggling `open` stopped working in `sl-alert`, `sl-dialog`, `sl-drawer`, `sl-dropdown`, and `sl-tooltip`
- Fixed a bug in `sl-range` where setting a value outside the default `min` or `max` would clamp the value [#448](https://github.com/shoelace-style/shoelace/issues/448)
- Fixed a bug in `sl-dropdown` where placement wouldn't adjust properly when shown [#447](https://github.com/shoelace-style/shoelace/issues/447)
- Fixed a bug in the internal `shimKeyframesHeightAuto` utility that caused `sl-details` to measure heights incorrectly [#445](https://github.com/shoelace-style/shoelace/issues/445)
- Fixed a number of imports that should have been type imports
- Updated Lit to 2.0.0-rc.2
- Updated esbuild to 0.12.4
## 2.0.0-beta.41
This release changes how components animate. In previous versions, CSS transitions were used for most show/hide animations. Transitions are problematic due to the way `transitionend` works. This event fires once _per transition_, and it's impossible to know which transition to look for when users can customize any possible CSS property. Because of this, components previously required the `opacity` property to transition. If a user were to prevent `opacity` from transitioning, the component wouldn't hide properly and the `sl-after-show|hide` events would never emit.
CSS animations, on the other hand, have a more reliable `animationend` event. Alas, `@keyframes` don't cascade and can't be injected into a shadow DOM via CSS, so there would be no good way to customize them.
The most elegant solution I found was to use the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API), which offers more control over animations at the expense of customizations being done in JavaScript. Fortunately, through the [Animation Registry](/getting-started/customizing#animations), you can customize animations globally and/or per component with a minimal amount of code.
- 🚨 BREAKING: changed `left` and `right` placements to `start` and `end` in `sl-drawer`
- 🚨 BREAKING: changed `left` and `right` placements to `start` and `end` in `sl-tab-group`
- 🚨 BREAKING: removed `--hide-duration`, `--hide-timing-function`, `--show-duration`, and `--show-timing-function` custom properties from `sl-tooltip` (use the Animation Registry instead)
- Added the Animation Registry
- Fixed a bug where removing `sl-dropdown` from the DOM and adding it back destroyed the popover reference [#443](https://github.com/shoelace-style/shoelace/issues/443)
- Updated animations for `sl-alert`, `sl-dialog`, `sl-drawer`, `sl-dropdown`, and `sl-tooltip` to use the Animation Registry instead of CSS transitions
- Improved a11y by respecting `prefers-reduced-motion` for all show/hide animations
- Improved `--show-delay` and `--hide-delay` behavior in `sl-tooltip` so they only apply on hover
- Removed the internal popover utility
## 2.0.0-beta.40
- 🚨 BREAKING: renamed `sl-responsive-embed` to `sl-responsive-media` and added support for images and videos [#436](https://github.com/shoelace-style/shoelace/issues/436)
- Fixed a bug where setting properties before an element was defined would render incorrectly [#425](https://github.com/shoelace-style/shoelace/issues/425)
- Fixed a bug that caused all modules to be imported when cherry picking certain components [#439](https://github.com/shoelace-style/shoelace/issues/439)
- Fixed a bug where the scrollbar would reposition `sl-dialog` on hide causing it to jump [#424](https://github.com/shoelace-style/shoelace/issues/424)
- Fixed a bug that prevented the project from being built in a Windows environment
- Improved a11y in `sl-progress-ring`
- Removed `src/utilities/index.ts` to prevent tree-shaking confusion (please import utilities directly from their respective modules)
- Removed global `[hidden]` styles so they don't affect anything outside of components
- Updated to Bootstrap Icons 1.5.0
- Updated React docs to use [`@shoelace-style/react`](https://github.com/shoelace-style/react)
- Updated NextJS docs [#434](https://github.com/shoelace-style/shoelace/pull/434)
- Updated TypeScript to 4.2.4
## 2.0.0-beta.39
- Added experimental `sl-qr-code` component
- Added `system` icon library and updated all components to use this instead of the default icon library [#420](https://github.com/shoelace-style/shoelace/issues/420)
- Updated to esbuild 0.8.57
- Updated to lit 2.0.0-rc.1 and lit-html 2.0.0-rc.2
- Updated to Lit 2.0.0-rc.1 and lit-html 2.0.0-rc.2
## 2.0.0-beta.38

View File

@@ -108,6 +108,29 @@ There is currently no hot module reloading (HMR), as browsers don't provide a wa
For more information about running and building the project locally, refer to `README.md` in the project's root.
### Testing
Shoelace uses [Web Test Runner](https://modern-web.dev/guides/test-runner/getting-started/) for testing. To launch the test runner during development, open a terminal and launch the dev server.
```bash
npm start
```
In a second terminal window, launch the test runner.
```bash
npm run test:watch
```
Follow the on-screen instructions to work with the test runner. Tests will automatically re-run as you make changes.
To run tests only once, make sure to build the project first.
```bash
npm run build
npm run test
```
## Best Practices
The following is a non-exhaustive list of conventions, patterns, and best practices we try to follow. As a contributor, we ask that you make a good faith effort to follow them as well. This ensures consistency and maintainability throughout the project.
@@ -188,16 +211,25 @@ This convention avoids the problem of browsers lowercasing attributes, causing s
### CSS Custom Properties
To expose custom properties as part of a component's API, scope them to the `:host` block and use the following syntax for comments so they appear in the generated docs. Do not use the `--sl-` prefix, as that is reserved for design tokens that live in the global scope.
To expose custom properties as part of a component's API, scope them to the `:host` block.
```css
/**
* @prop --color: The component's text color.
* @prop --background-color: The component's background color.
*/
```scss
:host {
--color: white;
--background-color: tomato;
--color: var(--sl-color-primary-500);
--background-color: var(--sl-color-gray-100);
}
```
Then use the following syntax for comments so they appear in the generated docs. Do not use the `--sl-` prefix, as that is reserved for design tokens that live in the global scope.
```js
/**
* @customProperty --color: The component's text color.
* @customProperty --background-color: The component's background color.
*/
@customElement('sl-example')
export default class SlExample {
// ...
}
```
@@ -232,7 +264,3 @@ Form controls should support validation through the following conventions:
- All form controls must have a `reportValidity()` method that report their validity during form submission
- All form controls should mirror their native validation attributes such as `required`, `pattern`, `minlength`, `maxlength`, etc. when possible
- All form controls must be serialized by `<sl-form>`
### Positioning Popovers
Shoelace uses an internal popover utility for dropdowns, tooltips, etc. This is a light abstraction of Popper.js designed to make positioning and transitioning things easy and consistent throughout the library. When possible, use this utility instead of relying on Popper directly. See `src/utilities/popover.ts` for details.

View File

@@ -14,7 +14,7 @@ This integration has been tested with the following:
To get started using Shoelace with NextJS, the following packages must be installed.
```bash
yarn add @shoelace-style/shoelace @shoelace-style/react-wrapper copy-webpack-plugin
yarn add @shoelace-style/shoelace @shoelace-style/react-wrapper copy-webpack-plugin next-compose-plugins next-transpile-modules
```
### Importing the Default Theme
@@ -22,7 +22,7 @@ yarn add @shoelace-style/shoelace @shoelace-style/react-wrapper copy-webpack-plu
The next step is to import Shoelace's default theme (stylesheet) in your `_app.js` file:
```css
@import '~@shoelace-style/shoelace/dist/themes/base';
import '@shoelace-style/shoelace/dist/themes/base.css';
```
### Defining Custom Elements
@@ -40,12 +40,16 @@ function CustomEls({ URL }) {
if (customEls.current) {
return;
}
setBasePath(`${URL}/static/static`);
const { setBasePath } = require("@shoelace-style/shoelace/dist/utilities/base-path");
// Define the components you intend to use
customElements.define("sl-alert", SlAlert);
customElements.define("sl-button", SlButton);
// ...
setBasePath(`${URL}/static/static`);
// This imports all components
require("@shoelace-style/shoelace/dist/shoelace");
// If you want to selectively import components, replace this line with your own definitions
// require("@shoelace-style/shoelace/dist/components/button/button");
customEls.current = true;
}, [URL, customEls]);
@@ -90,7 +94,7 @@ MyApp.getInitialProps = async (context) => {
const URL = process.env.BASE_URL;
return {
URL,
URL
};
};
```
@@ -104,31 +108,30 @@ Next we need to add Shoelace's assets to the final build output. To do this, mod
```javascript
const path = require("path");
const CopyPlugin = require("copy-webpack-plugin");
const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules')(['@shoelace-style/shoelace']);
module.exports = {
webpack: (config) => {
config.plugins.push(
new CopyPlugin({
patterns: [
{
from: path.resolve(
__dirname,
"node_modules/@shoelace-style/shoelace/dist/assets"
),
to: path.resolve(__dirname, "static/assets"),
},
],
})
);
return config;
},
};
module.exports = withPlugins([withTM], {
webpack: config => {
config.plugins.push(
new CopyPlugin({
patterns: [
{
from: path.resolve(__dirname, 'node_modules/@shoelace-style/shoelace/dist/assets/icons'),
to: path.resolve(__dirname, 'static/icons')
}
]
})
);
return config;
}
});
```
?> This will copy the files from `node_modules` into your `static` folder on every development serve or build. You may want to avoid commiting these into your repo. To do so, simply add `static/assets` into your `.gitignore` folder
## Additional Resources
- There is a third-party [example repo](https://github.com/crutchcorn/nextjs-shoelace-example), courtesy of [crutchcorn](https://github.com/crutchcorn) available to help you get started.
- There is a third-party [example repo](https://github.com/crutchcorn/nextjs-shoelace-example), courtesy of [crutchcorn](https://github.com/crutchcorn), available to help you get started.

6984
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.0.0-beta.39",
"version": "2.0.0-beta.44",
"homepage": "https://github.com/shoelace-style/shoelace",
"author": "Cory LaViska",
"license": "MIT",
@@ -29,46 +29,50 @@
"url": "https://github.com/sponsors/claviska"
},
"scripts": {
"start": "node scripts/build.cjs --dev",
"build": "node scripts/build.cjs",
"prepublishOnly": "npm run build",
"start": "node scripts/build.js --dev",
"build": "node scripts/build.js",
"prepublishOnly": "npm run build && npm run test",
"prettier": "prettier --write --loglevel warn .",
"create": "node scripts/create-component.cjs"
"create": "node scripts/create-component.js",
"test": "web-test-runner \"src/**/*.test.ts\" --node-resolve --puppeteer",
"test:watch": "web-test-runner \"src/**/*.test.ts\" --node-resolve --puppeteer --watch"
},
"dependencies": {
"@popperjs/core": "^2.7.0",
"@shoelace-style/animations": "^1.1.0",
"color": "^3.1.3",
"lit": "^2.0.0-rc.1",
"globby": "^11.0.3",
"lit": "^2.0.0-rc.2",
"lit-html": "^2.0.0-rc.2",
"qr-creator": "^1.0.0"
},
"devDependencies": {
"@open-wc/testing": "^2.5.33",
"@types/color": "^3.0.1",
"@types/resize-observer-browser": "^0.1.5",
"@web/dev-server-esbuild": "^0.2.12",
"@web/test-runner": "^0.13.5",
"@web/test-runner-puppeteer": "^0.10.0",
"bluebird": "^3.7.2",
"bootstrap-icons": "^1.4.1",
"browser-sync": "^2.26.14",
"chalk": "^4.1.0",
"command-line-args": "^5.1.1",
"comment-parser": "^1.1.2",
"concurrently": "^5.3.0",
"del": "^6.0.0",
"download": "^8.0.0",
"esbuild": "^0.8.54",
"esbuild": "^0.12.4",
"esbuild-plugin-inline-import": "^1.0.0",
"esbuild-plugin-sass": "^0.3.3",
"front-matter": "^4.0.2",
"get-port": "^5.1.1",
"glob": "^7.1.6",
"husky": "^4.3.8",
"prettier": "^2.2.1",
"recursive-copy": "^2.0.11",
"sass": "^1.32.7",
"tiny-glob": "^0.2.8",
"tslib": "^2.1.0",
"sinon": "^11.1.1",
"tslib": "^2.2.0",
"typedoc": "^0.20.28",
"typescript": "4.1.5",
"typescript": "^4.2.4",
"wait-on": "^5.2.1"
},
"husky": {

View File

@@ -1,38 +1,40 @@
//
// Builds the project. To spin up a dev server, pass the --serve flag.
//
const bs = require('browser-sync').create();
const chalk = require('chalk');
const commandLineArgs = require('command-line-args');
const copy = require('recursive-copy');
const del = require('del');
const esbuild = require('esbuild');
const execSync = require('child_process').execSync;
const getPort = require('get-port');
const glob = require('tiny-glob');
const inlineImportPlugin = require('esbuild-plugin-inline-import');
const path = require('path');
const sass = require('sass');
const sassPlugin = require('esbuild-plugin-sass');
const { build } = require('esbuild');
import browserSync from 'browser-sync';
import chalk from 'chalk';
import commandLineArgs from 'command-line-args';
import copy from 'recursive-copy';
import del from 'del';
import esbuild from 'esbuild';
import { execSync } from 'child_process';
import getPort from 'get-port';
import glob from 'globby';
import inlineImportPlugin from 'esbuild-plugin-inline-import';
import path from 'path';
import sass from 'sass';
import sassPlugin from 'esbuild-plugin-sass';
const build = esbuild.build;
const bs = browserSync.create();
const { dev } = commandLineArgs({ name: 'dev', type: Boolean });
execSync(`rm -rf ./dist`, { stdio: 'inherit' });
del.sync('./dist');
if (!dev) execSync('tsc', { stdio: 'inherit' }); // for type declarations
execSync('node scripts/make-metadata.cjs', { stdio: 'inherit' });
execSync('node scripts/make-icons.cjs', { stdio: 'inherit' });
execSync('node scripts/make-metadata.js', { stdio: 'inherit' });
execSync('node scripts/make-icons.js', { stdio: 'inherit' });
(async () => {
const entryPoints = [
// The whole shebang dist
'./src/shoelace.ts',
// Components
...(await glob('./src/components/**/*.ts')),
...(await glob('./src/components/**/!(*.test).ts')),
// Public utilities
...(await glob('./src/utilities/**/*.ts')),
...(await glob('./src/utilities/**/!(*.test).ts')),
// Theme stylesheets
...(await glob('./src/themes/**/*.ts'))
...(await glob('./src/themes/**/!(*.test).ts'))
];
const buildResult = await esbuild
@@ -114,7 +116,7 @@ execSync('node scripts/make-icons.cjs', { stdio: 'inherit' });
});
// Rebuild and reload when source files change
bs.watch(['src/**/*']).on('change', async filename => {
bs.watch(['src/**/!(*.test).*']).on('change', async filename => {
console.log(`Source file changed - ${filename}`);
// NOTE: we don't run TypeDoc on every change because it's quite heavy, so changes to the docs won't be included

View File

@@ -1,17 +1,19 @@
const args = process.argv.slice(2);
const chalk = require('chalk');
const fs = require('fs');
const mkdirp = require('mkdirp');
const path = require('path');
import chalk from 'chalk';
import fs from 'fs';
import mkdirp from 'mkdirp';
import path from 'path';
import process from 'process';
const args = process.argv.slice(2);
const tagName = (args[0] + '').toLowerCase().trim();
const tagNameWithoutPrefix = tagName.replace(/^sl-/, '');
const className = tagName.replace(/(^\w|-\w)/g, string => string.replace(/-/, '').toUpperCase());
const readableName = tagNameWithoutPrefix
.replace(/-/g, ' ')
.replace(/\w\S*/g, string => string.charAt(0).toUpperCase() + string.substr(1).toLowerCase());
const version = require('../package.json').version;
const minorVersion = version.split('.').slice(0, 2).join('.');
const packageData = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const minorVersion = packageData.version.split('.').slice(0, 2).join('.');
// Check for tag name
if (!tagName) {
@@ -34,7 +36,7 @@ if (fs.existsSync(componentFile)) {
const componentSource = `
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators';
import { customElement } from 'lit/decorators.js';
import styles from 'sass:./${tagNameWithoutPrefix}.scss';
/**
@@ -48,7 +50,11 @@ import styles from 'sass:./${tagNameWithoutPrefix}.scss';
* @slot example - A named slot called example.
*
* @part base - The component's base wrapper.
* @part example - Another part called example.
*
* @customProperty example - An example custom property
*
* @animation example.show - An example animation.
* @animation example.hide - An example animation.
*/
@customElement('${tagName}')
export default class ${className} extends LitElement {
@@ -75,9 +81,6 @@ const stylesFile = `src/components/${tagNameWithoutPrefix}/${tagNameWithoutPrefi
const stylesSource = `
@use '../../styles/component';
/**
* @prop --custom-property-here: Description here
*/
:host {
display: block;
}

View File

@@ -1,30 +1,30 @@
//
// This script downloads and generates icons and icon metadata.
//
const Promise = require('bluebird');
const promisify = require('util').promisify;
const chalk = require('chalk');
const copy = require('recursive-copy');
const del = require('del');
const download = require('download');
const mkdirp = require('mkdirp');
const fm = require('front-matter');
const fs = require('fs').promises;
const glob = promisify(require('glob'));
const path = require('path');
import Promise from 'bluebird';
import chalk from 'chalk';
import copy from 'recursive-copy';
import del from 'del';
import download from 'download';
import mkdirp from 'mkdirp';
import fm from 'front-matter';
import { readFileSync } from 'fs';
import { stat, readFile, writeFile } from 'fs/promises';
import glob from 'globby';
import path from 'path';
const baseDir = path.dirname(__dirname);
const iconDir = './dist/assets/icons';
const iconPackageData = JSON.parse(readFileSync('./node_modules/bootstrap-icons/package.json', 'utf8'));
let numIcons = 0;
(async () => {
try {
const version = require('bootstrap-icons/package').version;
const version = iconPackageData.version;
const srcPath = `./.cache/icons/icons-${version}`;
const url = `https://github.com/twbs/icons/archive/v${version}.zip`;
try {
await fs.stat(`${srcPath}/LICENSE.md`);
await stat(`${srcPath}/LICENSE.md`);
console.log(chalk.cyan('Generating icons from cache'));
} catch {
// Download the source from GitHub (since not everything is published to NPM)
@@ -48,7 +48,7 @@ let numIcons = 0;
const metadata = await Promise.map(files, async file => {
const name = path.basename(file, path.extname(file));
const data = fm(await fs.readFile(file, 'utf8')).attributes;
const data = fm(await readFile(file, 'utf8')).attributes;
numIcons++;
return {
@@ -59,7 +59,7 @@ let numIcons = 0;
};
});
await fs.writeFile(path.join(iconDir, 'icons.json'), JSON.stringify(metadata, null, 2), 'utf8');
await writeFile(path.join(iconDir, 'icons.json'), JSON.stringify(metadata, null, 2), 'utf8');
console.log(chalk.green(`Successfully processed ${numIcons} icons ✨\n`));
} catch (err) {

View File

@@ -1,13 +1,13 @@
//
// This script runs TypeDoc and uses its output to generate metadata files used by the docs
//
const chalk = require('chalk');
const execSync = require('child_process').execSync;
const fs = require('fs');
const mkdirp = require('mkdirp');
const path = require('path');
const package = require('../package.json');
const { parse } = require('comment-parser/lib');
import chalk from 'chalk';
import { execSync } from 'child_process';
import fs from 'fs';
import mkdirp from 'mkdirp';
import path from 'path';
const packageData = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
function getTagName(className) {
return className.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
@@ -32,6 +32,18 @@ function getTypeInfo(item) {
});
}
if (item.type.type === 'reflection' && item.type.declaration?.children) {
const args = item.type.declaration.children.map(prop => {
const name = prop.name;
const type = prop.type.name;
const isOptional = prop.flags.isOptional === true;
return `${name}${isOptional ? '?' : ''}: ${type}`;
});
// Display as an object
type += `{ ${args.join(', ')} }`;
}
return {
type,
values: values.length ? values : undefined
@@ -67,12 +79,12 @@ const data = JSON.parse(fs.readFileSync('.cache/typedoc.json', 'utf8'));
const modules = data.children;
const components = modules.filter(module => module.kindString === 'Class');
const metadata = {
name: package.name,
description: package.description,
version: package.version,
author: package.author,
homepage: package.homepage,
license: package.license,
name: packageData.name,
description: packageData.description,
version: packageData.version,
author: packageData.author,
homepage: packageData.homepage,
license: packageData.license,
components: []
};
@@ -98,12 +110,16 @@ components.map(async component => {
const dependencies = tags.filter(item => item.tag === 'dependency');
const slots = tags.filter(item => item.tag === 'slot');
const parts = tags.filter(item => item.tag === 'part');
const customProperties = tags.filter(item => item.tag === 'customproperty');
const animations = tags.filter(item => item.tag === 'animation');
api.since = tags.find(item => item.tag === 'since').text.trim();
api.status = tags.find(item => item.tag === 'status').text.trim();
api.dependencies = dependencies.map(tag => tag.text.trim());
api.slots = slots.map(tag => splitText(tag.text));
api.parts = parts.map(tag => splitText(tag.text));
api.cssCustomProperties = customProperties.map(tag => splitText(tag.text));
api.animations = animations.map(tag => splitText(tag.text));
} else {
console.error(chalk.yellow(`Missing comment block for ${component.name} - skipping metadata`));
}
@@ -116,9 +132,29 @@ components.map(async component => {
props.map(prop => {
const { type, values } = getTypeInfo(prop);
let attribute;
// Look for an attribute in the @property decorator
if (Array.isArray(prop.decorators)) {
const decorator = prop.decorators.find(d => d.name === 'property');
if (decorator) {
try {
// We trust TypeDoc <3
const options = eval(`(${decorator.arguments.options})`);
// If an attribute is specified, it will always be a string
if (options && typeof options.attribute === 'string') {
attribute = options.attribute;
}
} catch (err) {
console.log(err);
}
}
}
api.props.push({
name: prop.name,
attribute: attribute,
description: prop.comment.shortText,
type,
values,
@@ -206,6 +242,7 @@ components.map(async component => {
name: param.name,
type,
values,
isOptional: param.flags?.isOptional,
defaultValue: param.defaultValue
};
})
@@ -218,17 +255,6 @@ components.map(async component => {
});
});
// CSS custom properties
const stylesheet = path.resolve(path.dirname(api.file), path.parse(api.file).name + '.scss');
if (fs.existsSync(stylesheet)) {
const styles = fs.readFileSync(stylesheet, 'utf8');
const parsed = parse(styles);
const tags = parsed[0] ? parsed[0].tags : [];
const cssCustomProperties = tags
.filter(tag => tag.tag === 'prop')
.map(tag => api.cssCustomProperties.push({ name: tag.name.slice(0, -1), description: tag.description }));
}
metadata.components.push(api);
});

View File

@@ -1,9 +1,6 @@
@use '../../styles/component';
@use '../../styles/mixins/hide';
/**
* @prop --box-shadow: The alert's box shadow.
*/
:host {
display: contents;
@@ -25,19 +22,7 @@
font-weight: var(--sl-font-weight-normal);
line-height: 1.6;
color: var(--sl-color-gray-700);
opacity: 0;
transform: scale(0.9);
transition: var(--sl-transition-medium) opacity ease, var(--sl-transition-medium) transform ease;
margin: inherit;
&:not(.alert--visible) {
@include hide.hidden;
}
}
.alert--open {
opacity: 1;
transform: none;
}
.alert__icon {

View File

@@ -0,0 +1,93 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlAlert from './alert';
describe('<sl-alert>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture(html` <sl-alert open>I am an alert</sl-alert> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
expect(base.hidden).to.be.false;
});
it('should not be visible without the open attribute', async () => {
const el = await fixture(html` <sl-alert>I am an alert</sl-alert> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
expect(base.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when calling show()', async () => {
const el = (await fixture(html` <sl-alert>I am an alert</sl-alert> `)) as SlAlert;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
const el = (await fixture(html` <sl-alert open>I am an alert</sl-alert> `)) as SlAlert;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when setting open = true', async () => {
const el = (await fixture(html` <sl-alert>I am an alert</sl-alert> `)) as SlAlert;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
const el = (await fixture(html` <sl-alert open>I am an alert</sl-alert> `)) as SlAlert;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.true;
});
});

View File

@@ -1,7 +1,10 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { animateTo, stopAnimations } from '../../internal/animate';
import { event, EventEmitter, watch } from '../../internal/decorators';
import { waitForEvent } from '../../internal/event';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import styles from 'sass:./alert.scss';
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
@@ -19,6 +22,11 @@ const toastStack = Object.assign(document.createElement('div'), { className: 'sl
* @part icon - The container that wraps the alert icon.
* @part message - The alert message.
* @part close-button - The close button.
*
* @customProperty --box-shadow - The alert's box shadow.
*
* @animation alert.show - The animation to use when showing the alert.
* @animation alert.hide - The animation to use when hiding the alert.
*/
@customElement('sl-alert')
export default class SlAlert extends LitElement {
@@ -26,7 +34,7 @@ export default class SlAlert extends LitElement {
private autoHideTimeout: any;
@state() private isVisible = false;
@query('[part="base"]') base: HTMLElement;
/** Indicates whether or not the alert is open. You can use this in lieu of the show/hide methods. */
@property({ type: Boolean, reflect: true }) open = false;
@@ -43,63 +51,40 @@ export default class SlAlert extends LitElement {
*/
@property({ type: Number }) duration = Infinity;
/** Emitted when the alert opens. Calling `event.preventDefault()` will prevent it from being opened. */
/** Emitted when the alert opens. */
@event('sl-show') slShow: EventEmitter<void>;
/** Emitted after the alert opens and all transitions are complete. */
@event('sl-after-show') slAfterShow: EventEmitter<void>;
/** Emitted when the alert closes. Calling `event.preventDefault()` will prevent it from being closed. */
/** Emitted when the alert closes. */
@event('sl-hide') slHide: EventEmitter<void>;
/** Emitted after the alert closes and all transitions are complete. */
@event('sl-after-hide') slAfterHide: EventEmitter<void>;
connectedCallback() {
super.connectedCallback();
// Show on init if open
if (this.open) {
this.show();
}
firstUpdated() {
this.base.hidden = !this.open;
}
/** Shows the alert. */
show() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
if (this.isVisible) {
async show() {
if (this.open) {
return;
}
const slShow = this.slShow.emit();
if (slShow.defaultPrevented) {
this.open = false;
return;
}
this.isVisible = true;
this.open = true;
if (this.duration < Infinity) {
this.autoHideTimeout = setTimeout(() => this.hide(), this.duration);
}
return waitForEvent(this, 'sl-after-show');
}
/** Hides the alert */
hide() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
async hide() {
if (!this.open) {
return;
}
const slHide = this.slHide.emit();
if (slHide.defaultPrevented) {
this.open = true;
return;
}
clearTimeout(this.autoHideTimeout);
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
/**
@@ -152,22 +137,38 @@ export default class SlAlert extends LitElement {
this.restartAutoHide();
}
handleTransitionEnd(event: TransitionEvent) {
const target = event.target as HTMLElement;
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.slShow.emit();
// Ensure we only emit one event when the target element is no longer visible
if (event.propertyName === 'opacity' && target.classList.contains('alert')) {
this.isVisible = this.open;
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
if (this.duration < Infinity) {
this.restartAutoHide();
}
await stopAnimations(this.base);
this.base.hidden = false;
const { keyframes, options } = getAnimation(this, 'alert.show');
await animateTo(this.base, keyframes, options);
this.slAfterShow.emit();
} else {
// Hide
this.slHide.emit();
clearTimeout(this.autoHideTimeout);
await stopAnimations(this.base);
const { keyframes, options } = getAnimation(this, 'alert.hide');
await animateTo(this.base, keyframes, options);
this.base.hidden = true;
this.slAfterHide.emit();
}
}
@watch('open')
handleOpenChange() {
this.open ? this.show() : this.hide();
}
@watch('duration')
@watch('duration', { waitUntilFirstUpdate: true })
handleDurationChange() {
this.restartAutoHide();
}
@@ -179,7 +180,6 @@ export default class SlAlert extends LitElement {
class=${classMap({
alert: true,
'alert--open': this.open,
'alert--visible': this.isVisible,
'alert--closable': this.closable,
'alert--primary': this.type === 'primary',
'alert--success': this.type === 'success',
@@ -192,7 +192,6 @@ export default class SlAlert extends LitElement {
aria-atomic="true"
aria-hidden=${this.open ? 'false' : 'true'}
@mousemove=${this.handleMouseMove.bind(this)}
@transitionend=${this.handleTransitionEnd.bind(this)}
>
<span part="icon" class="alert__icon">
<slot name="icon"></slot>
@@ -219,6 +218,22 @@ export default class SlAlert extends LitElement {
}
}
setDefaultAnimation('alert.show', {
keyframes: [
{ opacity: 0, transform: 'scale(0.8)' },
{ opacity: 1, transform: 'scale(1)' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('alert.hide', {
keyframes: [
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(0.8)' }
],
options: { duration: 250, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-alert': SlAlert;

View File

@@ -1,8 +1,8 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, queryAsync } from 'lit/decorators';
import { customElement, property, queryAsync } from 'lit/decorators.js';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./animation.scss';
import { animations } from './animations';
import styles from 'sass:./animation.scss';
/**
* @since 2.0
@@ -93,7 +93,11 @@ export default class SlAnimation extends LitElement {
@watch('iterations')
@watch('iterationsStart')
@watch('keyframes')
handleAnimationChange() {
async handleAnimationChange() {
if (!this.hasUpdated) {
return;
}
this.createAnimation();
}

View File

@@ -1,8 +1,5 @@
@use '../../styles/component';
/**
* @prop --size: The size of the avatar.
*/
:host {
display: inline-block;

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import styles from 'sass:./avatar.scss';
@@ -15,6 +15,8 @@ import styles from 'sass:./avatar.scss';
* @part icon - The container that wraps the avatar icon.
* @part initials - The container that wraps the avatar initials.
* @part image - The avatar image.
*
* @customProperty --size - The size of the avatar.
*/
@customElement('sl-avatar')
export default class SlAvatar extends LitElement {

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import styles from 'sass:./badge.scss';

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import { customElement, property, query } from 'lit/decorators.js';
import styles from 'sass:./button-group.scss';
/**

View File

@@ -1,10 +1,10 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { event, EventEmitter } from '../../internal/decorators';
import styles from 'sass:./button.scss';
import { hasSlot } from '../../internal/slot';
import styles from 'sass:./button.scss';
/**
* @since 2.0

View File

@@ -1,11 +1,5 @@
@use '../../styles/component';
/**
* @prop --border-color: The card's border color, including borders that occur inside the card.
* @prop --border-radius: The border radius for card edges.
* @prop --border-width: The width of card borders.
* @prop --padding: The padding to use for card sections.
*/
:host {
--border-color: var(--sl-color-gray-200);
--border-radius: var(--sl-border-radius-medium);

View File

@@ -1,8 +1,8 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, state } from 'lit/decorators';
import { customElement, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import styles from 'sass:./card.scss';
import { hasSlot } from '../../internal/slot';
import styles from 'sass:./card.scss';
/**
* @since 2.0
@@ -18,6 +18,11 @@ import { hasSlot } from '../../internal/slot';
* @part header - The card's header, if present.
* @part body - The card's body.
* @part footer - The card's footer, if present.
*
* @customProperty --border-color - The card's border color, including borders that occur inside the card.
* @customProperty --border-radius - The border radius for card edges.
* @customProperty --border-width - The width of card borders.
* @customProperty --padding - The padding to use for card sections.*
*/
@customElement('sl-card')
export default class SlCard extends LitElement {
@@ -27,11 +32,6 @@ export default class SlCard extends LitElement {
@state() private hasImage = false;
@state() private hasHeader = false;
connectedCallback() {
super.connectedCallback();
this.handleSlotChange();
}
handleSlotChange() {
this.hasFooter = hasSlot(this, 'footer');
this.hasImage = hasSlot(this, 'image');
@@ -50,11 +50,11 @@ export default class SlCard extends LitElement {
})}
>
<div part="image" class="card__image">
<slot name="image" onslotchange=${this.handleSlotChange}></slot>
<slot name="image" @slotchange=${this.handleSlotChange}></slot>
</div>
<div part="header" class="card__header">
<slot name="header" onslotchange=${this.handleSlotChange}></slot>
<slot name="header" @slotchange=${this.handleSlotChange}></slot>
</div>
<div part="body" class="card__body">
@@ -62,7 +62,7 @@ export default class SlCard extends LitElement {
</div>
<div part="footer" class="card__footer">
<slot name="footer" onslotchange=${this.handleSlotChange}></slot>
<slot name="footer" @slotchange=${this.handleSlotChange}></slot>
</div>
</div>
`;

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { event, EventEmitter, watch } from '../../internal/decorators';
@@ -61,7 +61,7 @@ export default class SlCheckbox extends LitElement {
@event('sl-focus') slFocus: EventEmitter<void>;
firstUpdated() {
this.input.indeterminate = this.indeterminate;
this.invalid = !this.input.checkValidity();
}
/** Simulates a click on the checkbox. */
@@ -111,11 +111,10 @@ export default class SlCheckbox extends LitElement {
this.input.focus();
}
@watch('checked')
@watch('indeterminate')
@watch('checked', { waitUntilFirstUpdate: true })
@watch('indeterminate', { waitUntilFirstUpdate: true })
handleStateChange() {
this.input.checked = this.checked;
this.input.indeterminate = this.indeterminate;
this.invalid = !this.input.checkValidity();
this.slChange.emit();
}
@@ -171,9 +170,10 @@ export default class SlCheckbox extends LitElement {
type="checkbox"
name=${ifDefined(this.name)}
value=${ifDefined(this.value)}
?checked=${this.checked}
?disabled=${this.disabled}
?required=${this.required}
.indeterminate=${this.indeterminate}
.checked=${this.checked}
.disabled=${this.disabled}
.required=${this.required}
role="checkbox"
aria-checked=${this.checked ? 'true' : 'false'}
aria-labelledby=${this.labelId}

View File

@@ -1,12 +1,5 @@
@use '../../styles/component';
/**
* @prop --grid-width: The width of the color grid.
* @prop --grid-height: The height of the color grid.
* @prop --grid-handle-size: The size of the color grid's handle.
* @prop --slider-height: The height of the hue and alpha sliders.
* @prop --slider-handle-size: The diameter of the slider's handle.
*/
:host {
--grid-width: 260px;
--grid-height: 200px;

View File

@@ -1,13 +1,14 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { styleMap } from 'lit-html/directives/style-map';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./color-picker.scss';
import { SlDropdown, SlInput } from '../../shoelace';
import color from 'color';
import { clamp } from '../../internal/math';
import type SlDropdown from '../dropdown/dropdown';
import type SlInput from '../input/input';
import color from 'color';
import styles from 'sass:./color-picker.scss';
/**
* @since 2.0
@@ -31,6 +32,12 @@ import { clamp } from '../../internal/math';
* @part preview - The preview color.
* @part input - The text input.
* @part format-button - The toggle format button's base.
*
* @customProperty --grid-width - The width of the color grid.
* @customProperty --grid-height - The height of the color grid.
* @customProperty --grid-handle-size - The size of the color grid's handle.
* @customProperty --slider-height - The height of the hue and alpha sliders.
* @customProperty --slider-handle-size - The diameter of the slider's handle.
*/
@customElement('sl-color-picker')
export default class SlColorPicker extends LitElement {
@@ -40,7 +47,6 @@ export default class SlColorPicker extends LitElement {
@query('[part="preview"]') previewButton: HTMLButtonElement;
@query('.color-dropdown') dropdown: SlDropdown;
private bypassValueParse = false;
private lastValueEmitted: string;
@state() private inputValue = '';
@@ -374,12 +380,6 @@ export default class SlColorPicker extends LitElement {
}
}
handleDropdownShow(event: CustomEvent) {
if (this.disabled) {
event.preventDefault();
}
}
handleDropdownAfterHide() {
this.showCopyFeedback = false;
}
@@ -551,38 +551,31 @@ export default class SlColorPicker extends LitElement {
this.inputValue = this.opacity ? currentColor.hexa : currentColor.hex;
}
// Setting this.value will trigger the watcher which parses the new value. We want to bypass that behavior because
// we've already parsed the color here and conversion/rounding can lead to values changing slightly. After the next
// update, the usual behavior is restored.
this.bypassValueParse = true;
this.value = this.inputValue;
this.updateComplete.then(() => (this.bypassValueParse = false));
}
@watch('format')
@watch('format', { waitUntilFirstUpdate: true })
handleFormatChange() {
this.syncValues();
}
@watch('opacity')
@watch('opacity', { waitUntilFirstUpdate: true })
handleOpacityChange() {
this.alpha = 100;
}
@watch('value')
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange(oldValue: string, newValue: string) {
if (!this.bypassValueParse) {
const newColor = this.parseColor(newValue);
const newColor = this.parseColor(newValue);
if (newColor) {
this.inputValue = this.value;
this.hue = newColor.hsla.h;
this.saturation = newColor.hsla.s;
this.lightness = newColor.hsla.l;
this.alpha = newColor.hsla.a * 100;
} else {
this.inputValue = oldValue;
}
if (newColor) {
this.inputValue = this.value;
this.hue = newColor.hsla.h;
this.saturation = newColor.hsla.s;
this.lightness = newColor.hsla.l;
this.alpha = newColor.hsla.a * 100;
} else {
this.inputValue = oldValue;
}
if (this.value !== this.lastValueEmitted) {
@@ -775,8 +768,8 @@ export default class SlColorPicker extends LitElement {
class="color-dropdown"
aria-disabled=${this.disabled ? 'true' : 'false'}
.containing-element=${this}
?disabled=${this.disabled}
?hoist=${this.hoist}
@sl-show=${this.handleDropdownShow}
@sl-after-hide=${this.handleDropdownAfterHide}
>
<button

View File

@@ -1,17 +1,6 @@
@use '../../styles/component';
/**
* @prop --hide-duration: The length of the hide transition.
* @prop --hide-timing-function: The timing function (easing) to use for the hide transition.
* @prop --show-duration: The length of the show transition.
* @prop --show-timing-function: The timing function (easing) to use for the show transition.
*/
:host {
--hide-duration: var(--sl-transition-medium);
--hide-timing-function: ease;
--show-duration: var(--sl-transition-medium);
--show-timing-function: ease;
display: block;
}
@@ -69,16 +58,7 @@
}
.details__body {
height: 0;
overflow: hidden;
transition-property: height;
transition-duration: var(--hide-duration);
transition-timing-function: var(--hide-timing-function);
}
.details--open .details__body {
transition-duration: var(--show-duration);
transition-timing-function: var(--show-timing-function);
}
.details__content {

View File

@@ -0,0 +1,152 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlDetails from './details';
describe('<sl-details>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture(html`
<sl-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</sl-details>
`);
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
expect(body.hidden).to.be.false;
});
it('should not be visible without the open attribute', async () => {
const el = await fixture(html`
<sl-details>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</sl-details>
`);
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
expect(body.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when calling show()', async () => {
const el = (await fixture(html`
<sl-details>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</sl-details>
`)) as SlDetails;
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(body.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
const el = (await fixture(html`
<sl-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</sl-details>
`)) as SlDetails;
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(body.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when setting open = true', async () => {
const el = (await fixture(html`
<sl-details>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</sl-details>
`)) as SlDetails;
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(body.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
const el = (await fixture(html`
<sl-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</sl-details>
`)) as SlDetails;
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(body.hidden).to.be.true;
});
it('should be the correct size after opening more than one instance', async () => {
const el = await fixture(html`
<div>
<sl-details>
<div style="height: 200px;"></div>
</sl-details>
<sl-details>
<div style="height: 400px;"></div>
</sl-details>
</div>
`);
const first = el.querySelectorAll('sl-details')[0];
const second = el.querySelectorAll('sl-details')[1];
const firstBody = first.shadowRoot?.querySelector('.details__body');
const secondBody = second.shadowRoot?.querySelector('.details__body');
await first.show();
await second.show();
expect(firstBody!.clientHeight).to.equal(200);
expect(secondBody!.clientHeight).to.equal(400);
});
});

View File

@@ -1,9 +1,12 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { animateTo, stopAnimations, shimKeyframesHeightAuto } from '../../internal/animate';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./details.scss';
import { waitForEvent } from '../../internal/event';
import { focusVisible } from '../../internal/focus-visible';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import styles from 'sass:./details.scss';
let id = 0;
@@ -21,6 +24,9 @@ let id = 0;
* @part summary - The details summary.
* @part summary-icon - The expand/collapse summary icon.
* @part content - The details content.
*
* @animation details.show - The animation to use when showing details. You can use `height: auto` with this animation.
* @animation details.hide - The animation to use when hiding details. You can use `height: auto` with this animation.
*/
@customElement('sl-details')
export default class SlDetails extends LitElement {
@@ -31,7 +37,6 @@ export default class SlDetails extends LitElement {
@query('.details__body') body: HTMLElement;
private componentId = `details-${++id}`;
private isVisible = false;
/** Indicates whether or not the details is open. You can use this in lieu of the show/hide methods. */
@property({ type: Boolean, reflect: true }) open = false;
@@ -42,13 +47,13 @@ export default class SlDetails extends LitElement {
/** Disables the details so it can't be toggled. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Emitted when the details opens. Calling `event.preventDefault()` will prevent it from being opened. */
/** Emitted when the details opens. */
@event('sl-show') slShow: EventEmitter<void>;
/** Emitted after the details opens and all transitions are complete. */
@event('sl-after-show') slAfterShow: EventEmitter<void>;
/** Emitted when the details closes. Calling `event.preventDefault()` will prevent it from being closed. */
/** Emitted when the details closes. */
@event('sl-hide') slHide: EventEmitter<void>;
/** Emitted after the details closes and all transitions are complete. */
@@ -56,12 +61,10 @@ export default class SlDetails extends LitElement {
connectedCallback() {
super.connectedCallback();
this.isVisible = this.open;
this.updateComplete.then(() => focusVisible.observe(this.details));
}
firstUpdated() {
focusVisible.observe(this.details);
this.body.hidden = !this.open;
this.body.style.height = this.open ? 'auto' : '0';
}
@@ -71,71 +74,24 @@ export default class SlDetails extends LitElement {
focusVisible.unobserve(this.details);
}
/** Shows the alert. */
show() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
if (this.isVisible || this.disabled) {
/** Shows the details. */
async show() {
if (this.open) {
return;
}
const slShow = this.slShow.emit();
if (slShow.defaultPrevented) {
this.open = false;
return;
}
this.body.hidden = false;
if (this.body.scrollHeight === 0) {
// When the scroll height can't be measured, use auto. This prevents a borked open state when the details is open
// intitially, but not immediately visible (i.e. in a tab panel).
this.body.style.height = 'auto';
this.body.style.overflow = 'visible';
} else {
this.body.style.height = `${this.body.scrollHeight}px`;
this.body.style.overflow = 'hidden';
}
this.isVisible = true;
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the alert */
hide() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
if (!this.isVisible || this.disabled) {
/** Hides the details */
async hide() {
if (!this.open) {
return;
}
const slHide = this.slHide.emit();
if (slHide.defaultPrevented) {
this.open = true;
return;
}
// We can't transition out of `height: auto`, so let's set it to the current height first
this.body.style.height = `${this.body.scrollHeight}px`;
this.body.style.overflow = 'hidden';
requestAnimationFrame(() => {
this.body.clientWidth; // force a reflow
this.body.style.height = '0';
});
this.isVisible = false;
this.open = false;
}
handleBodyTransitionEnd(event: TransitionEvent) {
const target = event.target as HTMLElement;
// Ensure we only emit one event when the target element is no longer visible
if (event.propertyName === 'height' && target.classList.contains('details__body')) {
this.body.style.overflow = this.open ? 'visible' : 'hidden';
this.body.style.height = this.open ? 'auto' : '0';
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
this.body.hidden = !this.open;
}
return waitForEvent(this, 'sl-after-hide');
}
handleSummaryClick() {
@@ -162,9 +118,33 @@ export default class SlDetails extends LitElement {
}
}
@watch('open')
handleOpenChange() {
this.open ? this.show() : this.hide();
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.slShow.emit();
await stopAnimations(this);
this.body.hidden = false;
const { keyframes, options } = getAnimation(this, 'details.show');
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
this.body.style.height = 'auto';
this.slAfterShow.emit();
} else {
// Hide
this.slHide.emit();
await stopAnimations(this);
const { keyframes, options } = getAnimation(this, 'details.hide');
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
this.body.hidden = true;
this.body.style.height = 'auto';
this.slAfterHide.emit();
}
}
render() {
@@ -198,7 +178,7 @@ export default class SlDetails extends LitElement {
</span>
</header>
<div class="details__body" @transitionend=${this.handleBodyTransitionEnd}>
<div class="details__body">
<div
part="content"
id=${`${this.componentId}-content`}
@@ -214,6 +194,22 @@ export default class SlDetails extends LitElement {
}
}
setDefaultAnimation('details.show', {
keyframes: [
{ height: '0', opacity: '0' },
{ height: 'auto', opacity: '1' }
],
options: { duration: 250, easing: 'linear' }
});
setDefaultAnimation('details.hide', {
keyframes: [
{ height: 'auto', opacity: '1' },
{ height: '0', opacity: '0' }
],
options: { duration: 250, easing: 'linear' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-details': SlDetails;

View File

@@ -1,12 +1,6 @@
@use '../../styles/component';
@use '../../styles/mixins/hide';
/**
* @prop --width: The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
* @prop --header-spacing: The amount of padding to use for the header.
* @prop --body-spacing: The amount of padding to use for the body.
* @prop --footer-spacing: The amount of padding to use for the footer.
*/
:host {
--width: 31rem;
--header-spacing: var(--sl-spacing-large);
@@ -26,10 +20,6 @@
bottom: 0;
left: 0;
z-index: var(--sl-z-index-dialog);
&:not(.dialog--visible) {
@include hide.hidden;
}
}
.dialog__panel {
@@ -42,9 +32,6 @@
background-color: var(--sl-panel-background-color);
border-radius: var(--sl-border-radius-medium);
box-shadow: var(--sl-shadow-x-large);
opacity: 0;
transform: scale(0.8);
transition: var(--sl-transition-medium) opacity, var(--sl-transition-medium) transform;
&:focus {
outline: none;
@@ -112,10 +99,4 @@
bottom: 0;
left: 0;
background-color: var(--sl-overlay-background-color);
opacity: 0;
transition: var(--sl-transition-medium) opacity;
}
.dialog--open .dialog__overlay {
opacity: 1;
}

View File

@@ -0,0 +1,132 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlDialog from './dialog';
describe('<sl-dialog>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture(html`
<sl-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
expect(base.hidden).to.be.false;
});
it('should not be visible without the open attribute', async () => {
const el = await fixture(html` <sl-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
expect(base.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when calling show()', async () => {
const el = (await fixture(html`
<sl-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`)) as SlDialog;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
const el = (await fixture(html`
<sl-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`)) as SlDialog;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when setting open = true', async () => {
const el = (await fixture(html`
<sl-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`)) as SlDialog;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
const el = (await fixture(html`
<sl-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`)) as SlDialog;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.true;
});
it('should not close when sl-overlay-dismiss is prevented', async () => {
const el = (await fixture(html`
<sl-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`)) as SlDialog;
const overlay = el.shadowRoot?.querySelector('[part="overlay"]') as HTMLElement;
el.addEventListener('sl-overlay-dismiss', event => event.preventDefault());
overlay.click();
expect(el.open).to.be.true;
});
it('should allow initial focus to be set', async () => {
const el = (await fixture(html` <sl-dialog><input /></sl-dialog> `)) as SlDialog;
const input = el.querySelector('input');
const initialFocusHandler = sinon.spy(event => {
event.preventDefault();
input.focus();
});
el.addEventListener('sl-initial-focus', initialFocusHandler);
el.show();
await waitUntil(() => initialFocusHandler.calledOnce);
expect(initialFocusHandler).to.have.been.calledOnce;
expect(document.activeElement).to.equal(input);
});
});

View File

@@ -1,12 +1,15 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { animateTo, stopAnimations } from '../../internal/animate';
import { event, EventEmitter, watch } from '../../internal/decorators';
import { waitForEvent } from '../../internal/event';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import { hasSlot } from '../../internal/slot';
import { isPreventScrollSupported } from '../../internal/support';
import Modal from '../../internal/modal';
import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry';
import styles from 'sass:./dialog.scss';
const hasPreventScroll = isPreventScrollSupported();
@@ -31,6 +34,16 @@ let id = 0;
* @part close-button - The close button.
* @part body - The dialog body.
* @part footer - The dialog footer.
*
* @customProperty --width - The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
* @customProperty --header-spacing - The amount of padding to use for the header.
* @customProperty --body-spacing - The amount of padding to use for the body.
* @customProperty --footer-spacing - The amount of padding to use for the footer.
*
* @animation dialog.show - The animation to use when showing the dialog.
* @animation dialog.hide - The animation to use when hiding the dialog.
* @animation dialog.overlay.show - The animation to use when showing the dialog's overlay.
* @animation dialog.overlay.hide - The animation to use when hiding the dialog's overlay.
*/
@customElement('sl-dialog')
export default class SlDialog extends LitElement {
@@ -38,15 +51,13 @@ export default class SlDialog extends LitElement {
@query('.dialog') dialog: HTMLElement;
@query('.dialog__panel') panel: HTMLElement;
@query('.dialog__overlay') overlay: HTMLElement;
private componentId = `dialog-${++id}`;
private modal: Modal;
private originalTrigger: HTMLElement | null;
private willShow = false;
private willHide = false;
@state() private hasFooter = false;
@state() private isVisible = false;
/** Indicates whether or not the dialog is open. You can use this in lieu of the show/hide methods. */
@property({ type: Boolean, reflect: true }) open = false;
@@ -63,13 +74,13 @@ export default class SlDialog extends LitElement {
*/
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
/** Emitted when the dialog opens. Calling `event.preventDefault()` will prevent it from being opened. */
/** Emitted when the dialog opens. */
@event('sl-show') slShow: EventEmitter<void>;
/** Emitted after the dialog opens and all transitions are complete. */
@event('sl-after-show') slAfterShow: EventEmitter<void>;
/** Emitted when the dialog closes. Calling `event.preventDefault()` will prevent it from being closed. */
/** Emitted when the dialog closes. */
@event('sl-hide') slHide: EventEmitter<void>;
/** Emitted after the dialog closes and all transitions are complete. */
@@ -89,11 +100,10 @@ export default class SlDialog extends LitElement {
this.modal = new Modal(this);
this.handleSlotChange();
}
// Show on init if open
if (this.open) {
this.show();
}
firstUpdated() {
this.dialog.hidden = !this.open;
}
disconnectedCallback() {
@@ -101,80 +111,24 @@ export default class SlDialog extends LitElement {
unlockBodyScrolling(this);
}
/** Shows the dialog */
show() {
if (this.willShow) {
return;
}
const slShow = this.slShow.emit();
if (slShow.defaultPrevented) {
this.open = false;
return;
}
this.originalTrigger = document.activeElement as HTMLElement;
this.willShow = true;
this.isVisible = true;
this.open = true;
this.modal.activate();
lockBodyScrolling(this);
/** Shows the dialog. */
async show() {
if (this.open) {
if (hasPreventScroll) {
// Wait for the next frame before setting initial focus so the dialog is technically visible
requestAnimationFrame(() => {
const slInitialFocus = this.slInitialFocus.emit();
if (!slInitialFocus.defaultPrevented) {
this.panel.focus({ preventScroll: true });
}
});
} else {
// Once Safari supports { preventScroll: true } we can remove this nasty little hack, but until then we need to
// wait for the transition to complete before setting focus, otherwise the panel may render in a buggy way
// that's out of view initially.
//
// Fiddle: https://jsfiddle.net/g6buoafq/1/
// Safari: https://bugs.webkit.org/show_bug.cgi?id=178583
//
this.dialog.addEventListener(
'transitionend',
() => {
const slInitialFocus = this.slInitialFocus.emit();
if (!slInitialFocus.defaultPrevented) {
this.panel.focus();
}
},
{ once: true }
);
}
return;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dialog */
hide() {
if (this.willHide) {
async hide() {
if (!this.open) {
return;
}
const slHide = this.slHide.emit();
if (slHide.defaultPrevented) {
this.open = true;
return;
}
this.willHide = true;
this.open = false;
this.modal.deactivate();
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (trigger && typeof trigger.focus === 'function') {
setTimeout(() => trigger.focus());
}
unlockBodyScrolling(this);
return waitForEvent(this, 'sl-after-hide');
}
handleCloseClick() {
@@ -188,13 +142,72 @@ export default class SlDialog extends LitElement {
}
}
@watch('open')
handleOpenChange() {
this.open ? this.show() : this.hide();
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.slShow.emit();
this.originalTrigger = document.activeElement as HTMLElement;
this.modal.activate();
lockBodyScrolling(this);
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
this.dialog.hidden = false;
// Browsers that support el.focus({ preventScroll }) can set initial focus immediately
if (hasPreventScroll) {
const slInitialFocus = this.slInitialFocus.emit({ cancelable: true });
if (!slInitialFocus.defaultPrevented) {
this.panel.focus({ preventScroll: true });
}
}
const panelAnimation = getAnimation(this, 'dialog.show');
const overlayAnimation = getAnimation(this, 'dialog.overlay.show');
await Promise.all([
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
]);
// Browsers that don't support el.focus({ preventScroll }) have to wait for the animation to finish before initial
// focus to prevent scrolling issues. See: https://caniuse.com/mdn-api_htmlelement_focus_preventscroll_option
if (!hasPreventScroll) {
const slInitialFocus = this.slInitialFocus.emit({ cancelable: true });
if (!slInitialFocus.defaultPrevented) {
this.panel.focus({ preventScroll: true });
}
}
this.slAfterShow.emit();
} else {
// Hide
this.slHide.emit();
this.modal.deactivate();
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
const panelAnimation = getAnimation(this, 'dialog.hide');
const overlayAnimation = getAnimation(this, 'dialog.overlay.hide');
await Promise.all([
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
]);
this.dialog.hidden = true;
unlockBodyScrolling(this);
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (trigger && typeof trigger.focus === 'function') {
setTimeout(() => trigger.focus());
}
this.slAfterHide.emit();
}
}
handleOverlayClick() {
const slOverlayDismiss = this.slOverlayDismiss.emit();
const slOverlayDismiss = this.slOverlayDismiss.emit({ cancelable: true });
if (!slOverlayDismiss.defaultPrevented) {
this.hide();
}
@@ -204,18 +217,6 @@ export default class SlDialog extends LitElement {
this.hasFooter = hasSlot(this, 'footer');
}
handleTransitionEnd(event: TransitionEvent) {
const target = event.target as HTMLElement;
// Ensure we only emit one event when the target element is no longer visible
if (event.propertyName === 'opacity' && target.classList.contains('dialog__panel')) {
this.isVisible = this.open;
this.willShow = false;
this.willHide = false;
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
}
}
render() {
return html`
<div
@@ -223,11 +224,9 @@ export default class SlDialog extends LitElement {
class=${classMap({
dialog: true,
'dialog--open': this.open,
'dialog--visible': this.isVisible,
'dialog--has-footer': this.hasFooter
})}
@keydown=${this.handleKeyDown}
@transitionend=${this.handleTransitionEnd}
>
<div part="overlay" class="dialog__overlay" @click=${this.handleOverlayClick} tabindex="-1"></div>
@@ -271,6 +270,32 @@ export default class SlDialog extends LitElement {
}
}
setDefaultAnimation('dialog.show', {
keyframes: [
{ opacity: 0, transform: 'scale(0.8)' },
{ opacity: 1, transform: 'scale(1)' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('dialog.hide', {
keyframes: [
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(0.8)' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('dialog.overlay.show', {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 250 }
});
setDefaultAnimation('dialog.overlay.hide', {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
options: { duration: 250 }
});
declare global {
interface HTMLElementTagNameMap {
'sl-dialog': SlDialog;

View File

@@ -1,14 +1,6 @@
@use '../../styles/component';
@use '../../styles/mixins/hide';
/**
* @prop --size: The preferred size of the drawer. This will be applied to the drawer's width or height depending on its
* `placement`. Note that the drawer will shrink to accommodate smaller screens.
* @prop --header-spacing: The amount of padding to use for the header.
* @prop --body-spacing: The amount of padding to use for the body.
* @prop --footer-spacing: The amount of padding to use for the footer.
*
*/
:host {
--size: 25rem;
--header-spacing: var(--sl-spacing-large);
@@ -25,10 +17,6 @@
height: 100%;
pointer-events: none;
overflow: hidden;
&:not(.drawer--visible) {
@include hide.hidden;
}
}
.drawer--contained {
@@ -66,17 +54,15 @@
left: 0;
width: 100%;
height: var(--size);
transform: translate(0, -100%);
}
.drawer--right .drawer__panel {
.drawer--end .drawer__panel {
top: 0;
right: 0;
bottom: auto;
left: auto;
width: var(--size);
height: 100%;
transform: translate(100%, 0);
}
.drawer--bottom .drawer__panel {
@@ -86,21 +72,15 @@
left: 0;
width: 100%;
height: var(--size);
transform: translate(0, 100%);
}
.drawer--left .drawer__panel {
.drawer--start .drawer__panel {
top: 0;
right: auto;
bottom: auto;
left: 0;
width: var(--size);
height: 100%;
transform: translate(-100%, 0);
}
.drawer--open .drawer__panel {
transform: translate(0, 0);
}
.drawer__header {
@@ -150,15 +130,9 @@
bottom: 0;
left: 0;
background-color: var(--sl-overlay-background-color);
opacity: 0;
transition: var(--sl-transition-medium) opacity;
pointer-events: all;
}
.drawer--contained .drawer__overlay {
position: absolute;
}
.drawer--open .drawer__overlay {
opacity: 1;
}

View File

@@ -0,0 +1,132 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlDrawer from './drawer';
describe('<sl-drawer>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture(html`
<sl-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
expect(base.hidden).to.be.false;
});
it('should not be visible without the open attribute', async () => {
const el = await fixture(html` <sl-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
expect(base.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when calling show()', async () => {
const el = (await fixture(html`
<sl-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`)) as SlDrawer;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
const el = (await fixture(html`
<sl-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`)) as SlDrawer;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when setting open = true', async () => {
const el = (await fixture(html`
<sl-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`)) as SlDrawer;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
const el = (await fixture(html`
<sl-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`)) as SlDrawer;
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(base.hidden).to.be.true;
});
it('should not close when sl-overlay-dismiss is prevented', async () => {
const el = (await fixture(html`
<sl-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`)) as SlDrawer;
const overlay = el.shadowRoot?.querySelector('[part="overlay"]') as HTMLElement;
el.addEventListener('sl-overlay-dismiss', event => event.preventDefault());
overlay.click();
expect(el.open).to.be.true;
});
it('should allow initial focus to be set', async () => {
const el = (await fixture(html` <sl-drawer><input /></sl-drawer> `)) as SlDrawer;
const input = el.querySelector('input');
const initialFocusHandler = sinon.spy(event => {
event.preventDefault();
input.focus();
});
el.addEventListener('sl-initial-focus', initialFocusHandler);
el.show();
await waitUntil(() => initialFocusHandler.calledOnce);
expect(initialFocusHandler).to.have.been.calledOnce;
expect(document.activeElement).to.equal(input);
});
});

View File

@@ -1,12 +1,16 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { animateTo, stopAnimations } from '../../internal/animate';
import { event, EventEmitter, watch } from '../../internal/decorators';
import { waitForEvent } from '../../internal/event';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import { hasSlot } from '../../internal/slot';
import { uppercaseFirstLetter } from '../../internal/string';
import { isPreventScrollSupported } from '../../internal/support';
import Modal from '../../internal/modal';
import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry';
import styles from 'sass:./drawer.scss';
const hasPreventScroll = isPreventScrollSupported();
@@ -31,6 +35,23 @@ let id = 0;
* @part close-button - The close button.
* @part body - The drawer body.
* @part footer - The drawer footer.
*
* @customProperty --size - The preferred size of the drawer. This will be applied to the drawer's width or height
* depending on its `placement`. Note that the drawer will shrink to accommodate smaller screens.
* @customProperty --header-spacing - The amount of padding to use for the header.
* @customProperty --body-spacing - The amount of padding to use for the body.
* @customProperty --footer-spacing - The amount of padding to use for the footer.
*
* @animation drawer.showTop - The animation to use when showing a drawer with `top` placement.
* @animation drawer.showEnd - The animation to use when showing a drawer with `end` placement.
* @animation drawer.showBottom - The animation to use when showing a drawer with `bottom` placement.
* @animation drawer.showStart - The animation to use when showing a drawer with `start` placement.
* @animation drawer.hideTop - The animation to use when hiding a drawer with `top` placement.
* @animation drawer.hideEnd - The animation to use when hiding a drawer with `end` placement.
* @animation drawer.hideBottom - The animation to use when hiding a drawer with `bottom` placement.
* @animation drawer.hideStart - The animation to use when hiding a drawer with `start` placement.
* @animation drawer.overlay.show - The animation to use when showing the drawer's overlay.
* @animation drawer.overlay.hide - The animation to use when hiding the drawer's overlay.
*/
@customElement('sl-drawer')
export default class SlDrawer extends LitElement {
@@ -38,15 +59,13 @@ export default class SlDrawer extends LitElement {
@query('.drawer') drawer: HTMLElement;
@query('.drawer__panel') panel: HTMLElement;
@query('.drawer__overlay') overlay: HTMLElement;
private componentId = `drawer-${++id}`;
private modal: Modal;
private originalTrigger: HTMLElement | null;
private willShow = false;
private willHide = false;
@state() private hasFooter = false;
@state() private isVisible = false;
/** Indicates whether or not the drawer is open. You can use this in lieu of the show/hide methods. */
@property({ type: Boolean, reflect: true }) open = false;
@@ -58,7 +77,7 @@ export default class SlDrawer extends LitElement {
@property({ reflect: true }) label = '';
/** The direction from which the drawer will open. */
@property({ reflect: true }) placement: 'top' | 'right' | 'bottom' | 'left' = 'right';
@property({ reflect: true }) placement: 'top' | 'end' | 'bottom' | 'start' = 'end';
/**
* By default, the drawer slides out of its containing block (usually the viewport). To make the drawer slide out of
@@ -72,13 +91,13 @@ export default class SlDrawer extends LitElement {
*/
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
/** Emitted when the drawer opens. Calling `event.preventDefault()` will prevent it from being opened. */
/** Emitted when the drawer opens. */
@event('sl-show') slShow: EventEmitter<void>;
/** Emitted after the drawer opens and all transitions are complete. */
@event('sl-after-show') slAfterShow: EventEmitter<void>;
/** Emitted when the drawer closes. Calling `event.preventDefault()` will prevent it from being closed. */
/** Emitted when the drawer closes. */
@event('sl-hide') slHide: EventEmitter<void>;
/** Emitted after the drawer closes and all transitions are complete. */
@@ -95,11 +114,10 @@ export default class SlDrawer extends LitElement {
this.modal = new Modal(this);
this.handleSlotChange();
}
// Show on init if open
if (this.open) {
this.show();
}
firstUpdated() {
this.drawer.hidden = !this.open;
}
disconnectedCallback() {
@@ -107,83 +125,24 @@ export default class SlDrawer extends LitElement {
unlockBodyScrolling(this);
}
/** Shows the drawer */
show() {
if (this.willShow) {
return;
}
const slShow = this.slShow.emit();
if (slShow.defaultPrevented) {
this.open = false;
return;
}
this.originalTrigger = document.activeElement as HTMLElement;
this.willShow = true;
this.isVisible = true;
this.open = true;
// Lock body scrolling only if the drawer isn't contained
if (!this.contained) {
this.modal.activate();
lockBodyScrolling(this);
}
/** Shows the drawer. */
async show() {
if (this.open) {
if (hasPreventScroll) {
// Wait for the next frame before setting initial focus so the drawer is technically visible
requestAnimationFrame(() => {
const slInitialFocus = this.slInitialFocus.emit();
if (!slInitialFocus.defaultPrevented) {
this.panel.focus({ preventScroll: true });
}
});
} else {
// Once Safari supports { preventScroll: true } we can remove this nasty little hack, but until then we need to
// wait for the transition to complete before setting focus, otherwise the panel may render in a buggy way its
// out of view initially.
//
// Fiddle: https://jsfiddle.net/g6buoafq/1/
// Safari: https://bugs.webkit.org/show_bug.cgi?id=178583
//
this.drawer.addEventListener(
'transitionend',
() => {
const slInitialFocus = this.slInitialFocus.emit();
if (!slInitialFocus.defaultPrevented) {
this.panel.focus();
}
},
{ once: true }
);
}
return;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the drawer */
hide() {
if (this.willHide) {
async hide() {
if (!this.open) {
return;
}
const slHide = this.slHide.emit();
if (slHide.defaultPrevented) {
this.open = true;
return;
}
this.willHide = true;
this.open = false;
this.modal.deactivate();
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (trigger && typeof trigger.focus === 'function') {
setTimeout(() => trigger.focus());
}
unlockBodyScrolling(this);
return waitForEvent(this, 'sl-after-hide');
}
handleCloseClick() {
@@ -197,13 +156,75 @@ export default class SlDrawer extends LitElement {
}
}
@watch('open')
handleOpenChange() {
this.open ? this.show() : this.hide();
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.slShow.emit();
this.originalTrigger = document.activeElement as HTMLElement;
// Lock body scrolling only if the drawer isn't contained
if (!this.contained) {
this.modal.activate();
lockBodyScrolling(this);
}
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
this.drawer.hidden = false;
// Browsers that support el.focus({ preventScroll }) can set initial focus immediately
if (hasPreventScroll) {
const slInitialFocus = this.slInitialFocus.emit({ cancelable: true });
if (!slInitialFocus.defaultPrevented) {
this.panel.focus({ preventScroll: true });
}
}
const panelAnimation = getAnimation(this, `drawer.show${uppercaseFirstLetter(this.placement)}`);
const overlayAnimation = getAnimation(this, 'drawer.overlay.show');
await Promise.all([
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
]);
// Browsers that don't support el.focus({ preventScroll }) have to wait for the animation to finish before initial
// focus to prevent scrolling issues. See: https://caniuse.com/mdn-api_htmlelement_focus_preventscroll_option
if (!hasPreventScroll) {
const slInitialFocus = this.slInitialFocus.emit({ cancelable: true });
if (!slInitialFocus.defaultPrevented) {
this.panel.focus({ preventScroll: true });
}
}
this.slAfterShow.emit();
} else {
// Hide
this.slHide.emit();
this.modal.deactivate();
unlockBodyScrolling(this);
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
const panelAnimation = getAnimation(this, `drawer.hide${uppercaseFirstLetter(this.placement)}`);
const overlayAnimation = getAnimation(this, 'drawer.overlay.hide');
await Promise.all([
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
]);
this.drawer.hidden = true;
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (trigger && typeof trigger.focus === 'function') {
setTimeout(() => trigger.focus());
}
this.slAfterHide.emit();
}
}
handleOverlayClick() {
const slOverlayDismiss = this.slOverlayDismiss.emit();
const slOverlayDismiss = this.slOverlayDismiss.emit({ cancelable: true });
if (!slOverlayDismiss.defaultPrevented) {
this.hide();
}
@@ -213,18 +234,6 @@ export default class SlDrawer extends LitElement {
this.hasFooter = hasSlot(this, 'footer');
}
handleTransitionEnd(event: TransitionEvent) {
const target = event.target as HTMLElement;
// Ensure we only emit one event when the target element is no longer visible
if (event.propertyName === 'transform' && target.classList.contains('drawer__panel')) {
this.isVisible = this.open;
this.willShow = false;
this.willHide = false;
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
}
}
render() {
return html`
<div
@@ -232,17 +241,15 @@ export default class SlDrawer extends LitElement {
class=${classMap({
drawer: true,
'drawer--open': this.open,
'drawer--visible': this.isVisible,
'drawer--top': this.placement === 'top',
'drawer--right': this.placement === 'right',
'drawer--end': this.placement === 'end',
'drawer--bottom': this.placement === 'bottom',
'drawer--left': this.placement === 'left',
'drawer--start': this.placement === 'start',
'drawer--contained': this.contained,
'drawer--fixed': !this.contained,
'drawer--has-footer': this.hasFooter
})}
@keydown=${this.handleKeyDown}
@transitionend=${this.handleTransitionEnd}
>
<div part="overlay" class="drawer__overlay" @click=${this.handleOverlayClick} tabindex="-1"></div>
@@ -287,6 +294,85 @@ export default class SlDrawer extends LitElement {
}
}
// Top
setDefaultAnimation('drawer.showTop', {
keyframes: [
{ opacity: 0, transform: 'translateY(-100%)' },
{ opacity: 1, transform: 'translateY(0)' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideTop', {
keyframes: [
{ opacity: 1, transform: 'translateY(0)' },
{ opacity: 0, transform: 'translateY(-100%)' }
],
options: { duration: 250, easing: 'ease' }
});
// End
setDefaultAnimation('drawer.showEnd', {
keyframes: [
{ opacity: 0, transform: 'translateX(100%)' },
{ opacity: 1, transform: 'translateX(0)' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideEnd', {
keyframes: [
{ opacity: 1, transform: 'translateX(0)' },
{ opacity: 0, transform: 'translateX(100%)' }
],
options: { duration: 250, easing: 'ease' }
});
// Bottom
setDefaultAnimation('drawer.showBottom', {
keyframes: [
{ opacity: 0, transform: 'translateY(100%)' },
{ opacity: 1, transform: 'translateY(0)' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideBottom', {
keyframes: [
{ opacity: 1, transform: 'translateY(0)' },
{ opacity: 0, transform: 'translateY(100%)' }
],
options: { duration: 250, easing: 'ease' }
});
// Start
setDefaultAnimation('drawer.showStart', {
keyframes: [
{ opacity: 0, transform: 'translateX(-100%)' },
{ opacity: 1, transform: 'translateX(0)' }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('drawer.hideStart', {
keyframes: [
{ opacity: 1, transform: 'translateX(0)' },
{ opacity: 0, transform: 'translateX(-100%)' }
],
options: { duration: 250, easing: 'ease' }
});
// Overlay
setDefaultAnimation('drawer.overlay.show', {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 250 }
});
setDefaultAnimation('drawer.overlay.hide', {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
options: { duration: 250 }
});
declare global {
interface HTMLElementTagNameMap {
'sl-drawer': SlDrawer;

View File

@@ -27,12 +27,13 @@
border: solid 1px var(--sl-panel-border-color);
border-radius: var(--sl-border-radius-medium);
box-shadow: var(--sl-shadow-large);
opacity: 0;
overflow: auto;
overscroll-behavior: none;
pointer-events: none;
transform: scale(0.9);
transition: var(--sl-transition-fast) opacity, var(--sl-transition-fast) transform;
}
.dropdown--open .dropdown__panel {
pointer-events: all;
}
.dropdown__positioner {
@@ -51,10 +52,4 @@
&[data-popper-placement^='right'] .dropdown__panel {
transform-origin: left;
}
&.popover-visible .dropdown__panel {
opacity: 1;
transform: none;
pointer-events: all;
}
}

View File

@@ -0,0 +1,147 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlDropdown from './dropdown';
describe('<sl-dropdown>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture(html`
<sl-dropdown open>
<sl-button slot="trigger" caret>Toggle</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>
`);
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
expect(panel.hidden).to.be.false;
});
it('should not be visible without the open attribute', async () => {
const el = await fixture(html`
<sl-dropdown>
<sl-button slot="trigger" caret>Toggle</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>
`);
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
expect(panel.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when calling show()', async () => {
const el = (await fixture(html`
<sl-dropdown>
<sl-button slot="trigger" caret>Toggle</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>
`)) as SlDropdown;
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
const el = (await fixture(html`
<sl-dropdown open>
<sl-button slot="trigger" caret>Toggle</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>
`)) as SlDropdown;
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when setting open = true', async () => {
const el = (await fixture(html`
<sl-dropdown>
<sl-button slot="trigger" caret>Toggle</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>
`)) as SlDropdown;
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
const el = (await fixture(html`
<sl-dropdown open>
<sl-button slot="trigger" caret>Toggle</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>
`)) as SlDropdown;
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
});

View File

@@ -1,12 +1,16 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { Instance as PopperInstance, createPopper } from '@popperjs/core/dist/esm';
import { animateTo, stopAnimations } from '../../internal/animate';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./dropdown.scss';
import { SlMenu, SlMenuItem } from '../../shoelace';
import { waitForEvent } from '../../internal/event';
import { scrollIntoView } from '../../internal/scroll';
import { getNearestTabbableElement } from '../../internal/tabbable';
import Popover from '../../internal/popover';
import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry';
import type SlMenu from '../menu/menu';
import type SlMenuItem from '../menu-item/menu-item';
import styles from 'sass:./dropdown.scss';
let id = 0;
@@ -20,6 +24,9 @@ let id = 0;
* @part base - The component's base wrapper.
* @part trigger - The container that wraps the trigger.
* @part panel - The panel that gets shown when the dropdown is open.
*
* @animation dropdown.show - The animation to use when showing the dropdown.
* @animation dropdown.hide - The animation to use when hiding the dropdown.
*/
@customElement('sl-dropdown')
export default class SlDropdown extends LitElement {
@@ -30,8 +37,7 @@ export default class SlDropdown extends LitElement {
@query('.dropdown__positioner') positioner: HTMLElement;
private componentId = `dropdown-${++id}`;
private isVisible = false;
private popover: Popover;
private popover: PopperInstance;
/** Indicates whether or not the dropdown is open. You can use this in lieu of the show/hide methods. */
@property({ type: Boolean, reflect: true }) open = false;
@@ -54,6 +60,9 @@ export default class SlDropdown extends LitElement {
| 'left-start'
| 'left-end' = 'bottom-start';
/** Disables the dropdown so the panel will not open. */
@property({ type: Boolean }) disabled = false;
/** Determines whether the dropdown should hide when a menu item is selected. */
@property({ attribute: 'close-on-select', type: Boolean, reflect: true }) closeOnSelect = true;
@@ -72,16 +81,16 @@ export default class SlDropdown extends LitElement {
*/
@property({ type: Boolean }) hoist = false;
/** Emitted when the dropdown opens. Calling `event.preventDefault()` will prevent it from being opened. */
/** Emitted when the dropdown opens. */
@event('sl-show') slShow: EventEmitter<void>;
/** Emitted after the dropdown opens and all transitions are complete. */
/** Emitted after the dropdown opens and all animations are complete. */
@event('sl-after-show') slAfterShow: EventEmitter<void>;
/** Emitted when the dropdown closes. Calling `event.preventDefault()` will prevent it from being closed. */
/** Emitted when the dropdown closes. */
@event('sl-hide') slHide: EventEmitter<void>;
/** Emitted after the dropdown closes and all transitions are complete. */
/** Emitted after the dropdown closes and all animations are complete. */
@event('sl-after-hide') slAfterHide: EventEmitter<void>;
connectedCallback() {
@@ -94,28 +103,32 @@ 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() {
this.popover = new Popover(this.trigger, this.positioner, {
strategy: this.hoist ? 'fixed' : 'absolute',
placement: this.placement,
distance: this.distance,
skidding: this.skidding,
transitionElement: this.panel,
onAfterHide: () => this.slAfterHide.emit(),
onAfterShow: () => this.slAfterShow.emit(),
onTransitionEnd: () => {
if (!this.open) {
this.panel.scrollTop = 0;
}
}
});
// Show on init if open
if (this.open) {
this.show();
}
this.panel.hidden = !this.open;
}
disconnectedCallback() {
@@ -204,10 +217,22 @@ export default class SlDropdown extends LitElement {
handlePopoverOptionsChange() {
if (this.popover) {
this.popover.setOptions({
strategy: this.hoist ? 'fixed' : 'absolute',
placement: this.placement,
distance: this.distance,
skidding: this.skidding
strategy: this.hoist ? 'fixed' : 'absolute',
modifiers: [
{
name: 'flip',
options: {
boundary: 'viewport'
}
},
{
name: 'offset',
options: {
offset: [this.skidding, this.distance]
}
}
]
});
}
}
@@ -302,50 +327,24 @@ export default class SlDropdown extends LitElement {
}
}
/** Shows the dropdown panel */
show() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
if (this.isVisible) {
/** Shows the dropdown panel. */
async show() {
if (this.open) {
return;
}
const slShow = this.slShow.emit();
if (slShow.defaultPrevented) {
this.open = false;
return;
}
this.panel.addEventListener('sl-activate', this.handleMenuItemActivate);
this.panel.addEventListener('sl-select', this.handlePanelSelect);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
this.isVisible = true;
this.open = true;
this.popover.show();
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dropdown panel */
hide() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
if (!this.isVisible) {
async hide() {
if (!this.open) {
return;
}
const slHide = this.slHide.emit();
if (slHide.defaultPrevented) {
this.open = true;
return;
}
this.panel.removeEventListener('sl-activate', this.handleMenuItemActivate);
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
this.isVisible = false;
this.open = false;
this.popover.hide();
return waitForEvent(this, 'sl-after-hide');
}
/**
@@ -357,14 +356,47 @@ export default class SlDropdown extends LitElement {
return;
}
this.popover.reposition();
this.popover.update();
}
@watch('open')
handleOpenChange() {
this.open ? this.show() : this.hide();
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.disabled) {
return;
}
this.updateAccessibleTrigger();
if (this.open) {
// Show
this.slShow.emit();
this.panel.addEventListener('sl-activate', this.handleMenuItemActivate);
this.panel.addEventListener('sl-select', this.handlePanelSelect);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
await stopAnimations(this);
this.popover.update();
this.panel.hidden = false;
const { keyframes, options } = getAnimation(this, 'dropdown.show');
await animateTo(this.panel, keyframes, options);
this.slAfterShow.emit();
} else {
// Hide
this.slHide.emit();
this.panel.removeEventListener('sl-activate', this.handleMenuItemActivate);
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
await stopAnimations(this);
const { keyframes, options } = getAnimation(this, 'dropdown.hide');
await animateTo(this.panel, keyframes, options);
this.panel.hidden = true;
this.slAfterHide.emit();
}
}
render() {
@@ -387,7 +419,7 @@ export default class SlDropdown extends LitElement {
<slot name="trigger" @slotchange=${this.handleTriggerSlotChange}></slot>
</span>
<!-- Position the panel with a wrapper since the popover makes use of translate. This let's us add transitions
<!-- Position the panel with a wrapper since the popover makes use of translate. This let's us add animations
on the panel without interfering with the position. -->
<div class="dropdown__positioner">
<div
@@ -405,6 +437,22 @@ export default class SlDropdown extends LitElement {
}
}
setDefaultAnimation('dropdown.show', {
keyframes: [
{ opacity: 0, transform: 'scale(0.9)' },
{ opacity: 1, transform: 'scale(1)' }
],
options: { duration: 150, easing: 'ease' }
});
setDefaultAnimation('dropdown.hide', {
keyframes: [
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(0.9)' }
],
options: { duration: 150, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sl-dropdown': SlDropdown;

View File

@@ -1,18 +1,16 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import { customElement, property, query } from 'lit/decorators.js';
import { event, EventEmitter } from '../../internal/decorators';
import type SlButton from '../button/button';
import type SlCheckbox from '../checkbox/checkbox';
import type SlColorPicker from '../color-picker/color-picker';
import type SlInput from '../input/input';
import type SlRadio from '../radio/radio';
import type SlRange from '../range/range';
import type SlSelect from '../select/select';
import type SlSwitch from '../switch/switch';
import type SlTextarea from '../textarea/textarea';
import styles from 'sass:./form.scss';
import {
SlButton,
SlCheckbox,
SlColorPicker,
SlInput,
SlRadio,
SlRange,
SlSelect,
SlSwitch,
SlTextarea
} from '../../shoelace';
interface FormControl {
tag: string;

View File

@@ -1,5 +1,5 @@
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators';
import { customElement, property } from 'lit/decorators.js';
import { formatBytes } from '../../internal/number';
/**

View File

@@ -1,5 +1,5 @@
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators';
import { customElement, property } from 'lit/decorators.js';
/**
* @since 2.0

View File

@@ -1,5 +1,5 @@
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators';
import { customElement, property } from 'lit/decorators.js';
/**
* @since 2.0

View File

@@ -1,9 +1,9 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import styles from 'sass:./icon-button.scss';
import { focusVisible } from '../../internal/focus-visible';
import styles from 'sass:./icon-button.scss';
/**
* @since 2.0
@@ -37,8 +37,9 @@ export default class SlIconButton extends LitElement {
/** Disables the button. */
@property({ type: Boolean, reflect: true }) disabled = false;
firstUpdated() {
focusVisible.observe(this.button);
connectedCallback() {
super.connectedCallback();
this.updateComplete.then(() => focusVisible.observe(this.button));
}
disconnectedCallback() {

View File

@@ -1,10 +1,10 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators';
import { customElement, property, state } from 'lit/decorators.js';
import { unsafeSVG } from 'lit-html/directives/unsafe-svg';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./icon.scss';
import { getIconLibrary, watchIcon, unwatchIcon } from './library';
import { requestIcon } from './request';
import styles from 'sass:./icon.scss';
const parser = new DOMParser();

View File

@@ -1,6 +1,6 @@
import { SlIcon } from '../../shoelace';
import defaultLibrary from './library.default';
import systemLibrary from './library.system';
import type SlIcon from '../icon/icon';
export type IconLibraryResolver = (name: string) => string;
export type IconLibraryMutator = (svg: SVGElement) => void;

View File

@@ -1,9 +1,5 @@
@use '../../styles/component';
/**
* @prop --divider-width: The width of the dividing line.
* @prop --handle-size: The size of the compare handle.
*/
:host {
--divider-width: 2px;
--handle-size: 2.5rem;

View File

@@ -1,9 +1,9 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import { customElement, property, query } from 'lit/decorators.js';
import { styleMap } from 'lit-html/directives/style-map';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./image-comparer.scss';
import { clamp } from '../../internal/math';
import styles from 'sass:./image-comparer.scss';
/**
* @since 2.0
@@ -20,6 +20,9 @@ import { clamp } from '../../internal/math';
* @part after - The container that holds the "after" image.
* @part divider - The divider that separates the images.
* @part handle - The handle that the user drags to expose the after image.
*
* @customProperty --divider-width - The width of the dividing line.
* @customProperty --handle-size - The size of the compare handle.
*/
@customElement('sl-image-comparer')
export default class SlImageComparer extends LitElement {
@@ -89,7 +92,7 @@ export default class SlImageComparer extends LitElement {
}
}
@watch('position')
@watch('position', { waitUntilFirstUpdate: true })
handlePositionChange() {
this.slChange.emit();
}

View File

@@ -0,0 +1,28 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlInclude from './include';
describe('<sl-include>', () => {
it('should load content and emit sl-load', async () => {
const el = await fixture(html` <sl-include src="https://jsonplaceholder.typicode.com/posts/1"></sl-include> `);
const loadHandler = sinon.spy();
el.addEventListener('sl-load', loadHandler);
await waitUntil(() => loadHandler.calledOnce);
expect(el.innerHTML).to.contain('"id": 1');
expect(loadHandler).to.have.been.calledOnce;
});
it('should emit sl-error when content cannot be loaded', async () => {
const el = await fixture(html` <sl-include src="https://404"></sl-include> `);
const loadHandler = sinon.spy();
el.addEventListener('sl-error', loadHandler);
await waitUntil(() => loadHandler.calledOnce);
expect(loadHandler).to.have.been.calledOnce;
});
});

View File

@@ -1,8 +1,8 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators';
import { customElement, property } from 'lit/decorators.js';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./include.scss';
import { requestInclude } from './request';
import styles from 'sass:./include.scss';
/**
* @since 2.0
@@ -30,11 +30,6 @@ export default class SlInclude extends LitElement {
/** Emitted when the included file fails to load due to an error. */
@event('sl-error') slError: EventEmitter<{ status: number }>;
connectedCallback() {
super.connectedCallback();
this.loadSource();
}
executeScript(script: HTMLScriptElement) {
// Create a copy of the script and swap it out so the browser executes it
const newScript = document.createElement('script');
@@ -44,7 +39,7 @@ export default class SlInclude extends LitElement {
}
@watch('src')
async loadSource() {
async handleSrcChange() {
try {
const src = this.src;
const file = await requestInclude(src, this.mode);

View File

@@ -1,9 +1,6 @@
@use '../../styles/component';
@use '../../styles/form-control';
/**
* @prop --focus-ring: The focus ring style to use when the control receives focus, a `box-shadow` property.
*/
:host {
--focus-ring: 0 0 0 var(--sl-focus-ring-width) var(--sl-focus-ring-color-primary);
display: block;

View File

@@ -1,11 +1,11 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { ifDefined } from 'lit-html/directives/if-defined';
import { classMap } from 'lit-html/directives/class-map';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./input.scss';
import { getLabelledBy, renderFormControl } from '../../internal/form-control';
import { hasSlot } from '../../internal/slot';
import styles from 'sass:./input.scss';
let id = 0;
@@ -32,6 +32,8 @@ let id = 0;
* @part password-toggle-button - The password toggle button.
* @part suffix - The input suffix container.
* @part help-text - The input help text.
*
* @customProperty --focus-ring - The focus ring style to use when the control receives focus, a `box-shadow` property.
*/
@customElement('sl-input')
export default class SlInput extends LitElement {
@@ -147,12 +149,11 @@ export default class SlInput extends LitElement {
connectedCallback() {
super.connectedCallback();
this.handleSlotChange = this.handleSlotChange.bind(this);
this.shadowRoot!.addEventListener('slotchange', this.handleSlotChange);
}
firstUpdated() {
this.handleSlotChange();
this.invalid = !this.input.checkValidity();
}
disconnectedCallback() {
@@ -258,10 +259,13 @@ export default class SlInput extends LitElement {
@watch('value')
handleValueChange() {
this.invalid = !this.input.checkValidity();
if (this.input) {
this.invalid = !this.input.checkValidity();
}
}
render() {
// NOTE - always bind value after min/max, otherwise it will be clamped
return renderFormControl(
{
inputId: this.inputId,
@@ -302,7 +306,6 @@ export default class SlInput extends LitElement {
class="input__control"
type=${this.type === 'password' && this.isPasswordVisible ? 'text' : this.type}
name=${ifDefined(this.name)}
.value=${this.value}
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
@@ -312,6 +315,7 @@ export default class SlInput extends LitElement {
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step)}
.value=${this.value}
autocapitalize=${ifDefined(this.autocapitalize)}
autocomplete=${ifDefined(this.autocomplete)}
autocorrect=${ifDefined(this.autocorrect)}

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators';
import { customElement } from 'lit/decorators.js';
import styles from 'sass:./menu-divider.scss';
/**

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import styles from 'sass:./menu-item.scss';

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators';
import { customElement } from 'lit/decorators.js';
import styles from 'sass:./menu-label.scss';
/**

View File

@@ -1,9 +1,9 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, query } from 'lit/decorators';
import { customElement, query } from 'lit/decorators.js';
import { event, EventEmitter } from '../../internal/decorators';
import styles from 'sass:./menu.scss';
import { SlMenuItem } from '../../shoelace';
import { getTextContent } from '../../internal/slot';
import type SlMenuItem from '../menu-item/menu-item';
import styles from 'sass:./menu.scss';
/**
* @since 2.0
@@ -49,7 +49,7 @@ export default class SlMenu extends LitElement {
syncItems() {
this.items = [...this.defaultSlot.assignedElements({ flatten: true })].filter(
(el: any) => el instanceof SlMenuItem && !el.disabled
(el: any) => el.tagName.toLowerCase() === 'sl-menu-item' && !el.disabled
) as [SlMenuItem];
}

View File

@@ -1,11 +1,5 @@
@use '../../styles/component';
/**
* @prop --height: The progress bar's height.
* @prop --track-color: The track color.
* @prop --indicator-color: The indicator color.
* @prop --label-color: The label color.
*/
:host {
--height: 16px;
--track-color: var(--sl-color-gray-200);

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { styleMap } from 'lit-html/directives/style-map';
import styles from 'sass:./progress-bar.scss';
@@ -13,6 +13,11 @@ import styles from 'sass:./progress-bar.scss';
* @part base - The component's base wrapper.
* @part indicator - The progress bar indicator.
* @part label - The progress bar label.
*
* @customProperty --height - The progress bar's height.
* @customProperty --track-color - The track color.
* @customProperty --indicator-color - The indicator color.
* @customProperty --label-color - The label color.
*/
@customElement('sl-progress-bar')
export default class SlProgressBar extends LitElement {
@@ -35,7 +40,7 @@ export default class SlProgressBar extends LitElement {
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${this.indeterminate ? null : this.percentage}"
aria-valuenow="${this.indeterminate ? '' : this.percentage}"
>
<div part="indicator" class="progress-bar__indicator" style=${styleMap({ width: this.percentage + '%' })}>
${!this.indeterminate

View File

@@ -1,9 +1,5 @@
@use '../../styles/component';
/**
* @prop --track-color: The track color.
* @prop --indicator-color: The indicator color.
*/
:host {
--track-color: var(--sl-color-gray-200);
--indicator-color: var(--sl-color-primary-500);

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import { customElement, property, query } from 'lit/decorators.js';
import { watch } from '../../internal/decorators';
import styles from 'sass:./progress-ring.scss';
@@ -11,6 +11,9 @@ import styles from 'sass:./progress-ring.scss';
*
* @part base - The component's base wrapper.
* @part label - The progress ring label.
*
* @customProperty --track-color - The track color.
* @customProperty --indicator-color - The indicator color.
*/
@customElement('sl-progress-ring')
export default class SlProgressRing extends LitElement {
@@ -31,7 +34,7 @@ export default class SlProgressRing extends LitElement {
this.updateProgress();
}
@watch('percentage')
@watch('percentage', { waitUntilFirstUpdate: true })
handlePercentageChange() {
this.updateProgress();
}
@@ -47,7 +50,14 @@ export default class SlProgressRing extends LitElement {
render() {
return html`
<div part="base" class="progress-ring">
<div
part="base"
class="progress-ring"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${this.percentage}"
>
<svg class="progress-ring__image" width=${this.size} height=${this.size}>
<circle
class="progress-ring__track"

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import { customElement, property, query } from 'lit/decorators.js';
import { styleMap } from 'lit-html/directives/style-map';
import { watch } from '../../internal/decorators';
import QrCreator from 'qr-creator';
@@ -35,7 +35,7 @@ export default class SlQrCode extends LitElement {
/** The edge radius of each module. Must be between 0 and 0.5. */
@property({ type: Number }) radius = 0;
/* The level of error correction to use. */
/** The level of error correction to use. */
@property({ attribute: 'error-correction' }) errorCorrection: 'L' | 'M' | 'Q' | 'H' = 'H';
firstUpdated() {
@@ -49,6 +49,10 @@ export default class SlQrCode extends LitElement {
@watch('size')
@watch('value')
generate() {
if (!this.hasUpdated) {
return;
}
QrCreator.render(
{
text: this.value,

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import styles from 'sass:./radio-group.scss';

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { event, EventEmitter, watch } from '../../internal/decorators';
@@ -97,7 +97,7 @@ export default class SlRadio extends LitElement {
return this.getAllRadios().filter(radio => radio !== this) as this[];
}
@watch('checked')
@watch('checked', { waitUntilFirstUpdate: true })
handleCheckedChange() {
if (this.checked) {
this.getSiblingRadios().map(radio => (radio.checked = false));
@@ -172,8 +172,8 @@ export default class SlRadio extends LitElement {
type="radio"
name=${ifDefined(this.name)}
value=${ifDefined(this.value)}
?checked=${this.checked}
?disabled=${this.disabled}
.checked=${this.checked}
.disabled=${this.disabled}
aria-checked=${this.checked ? 'true' : 'false'}
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-labelledby=${this.labelId}

View File

@@ -1,11 +1,11 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./range.scss';
import { getLabelledBy, renderFormControl } from '../../internal/form-control';
import { hasSlot } from '../../internal/slot';
import styles from 'sass:./range.scss';
let id = 0;
@@ -85,6 +85,7 @@ export default class SlRange extends LitElement {
connectedCallback() {
super.connectedCallback();
this.handleSlotChange = this.handleSlotChange.bind(this);
this.resizeObserver = new ResizeObserver(() => this.syncTooltip());
this.shadowRoot!.addEventListener('slotchange', this.handleSlotChange);
if (this.value === undefined || this.value === null) this.value = this.min;
@@ -92,15 +93,16 @@ export default class SlRange extends LitElement {
if (this.value > this.max) this.value = this.max;
this.handleSlotChange();
}
firstUpdated() {
this.syncTooltip();
this.resizeObserver = new ResizeObserver(() => this.syncTooltip());
this.updateComplete.then(() => {
this.syncTooltip();
this.resizeObserver.observe(this.input);
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver.unobserve(this.input);
this.shadowRoot!.removeEventListener('slotchange', this.handleSlotChange);
}
@@ -131,14 +133,12 @@ export default class SlRange extends LitElement {
this.hasFocus = false;
this.hasTooltip = false;
this.slBlur.emit();
this.resizeObserver.unobserve(this.input);
}
handleFocus() {
this.hasFocus = true;
this.hasTooltip = true;
this.slFocus.emit();
this.resizeObserver.observe(this.input);
}
@watch('label')
@@ -165,6 +165,7 @@ export default class SlRange extends LitElement {
}
render() {
// NOTE - always bind value after min/max, otherwise it will be clamped
return renderFormControl(
{
inputId: this.inputId,
@@ -193,12 +194,12 @@ export default class SlRange extends LitElement {
part="input"
type="range"
class="range__control"
name=${this.name}
.value=${this.value + ''}
name=${ifDefined(this.name)}
?disabled=${this.disabled}
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step)}
.value=${String(this.value)}
aria-labelledby=${ifDefined(
getLabelledBy({
label: this.label,

View File

@@ -1,11 +1,5 @@
@use '../../styles/component';
/**
* @prop --symbol-color: The inactive color for symbols.
* @prop --symbol-color-active: The active color for symbols.
* @prop --symbol-size: The size of symbols.
* @prop --symbol-spacing: The spacing to use around symbols.
*/
:host {
--symbol-color: var(--sl-color-gray-300);
--symbol-color-active: #ffbe00;

View File

@@ -1,12 +1,12 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { styleMap } from 'lit-html/directives/style-map';
import { unsafeHTML } from 'lit-html/directives/unsafe-html';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./rating.scss';
import { focusVisible } from '../../internal/focus-visible';
import { clamp } from '../../internal/math';
import styles from 'sass:./rating.scss';
/**
* @since 2.0
@@ -15,6 +15,11 @@ import { clamp } from '../../internal/math';
* @dependency sl-icon
*
* @part base - The component's base wrapper.
*
* @customProperty --symbol-color - The inactive color for symbols.
* @customProperty --symbol-color-active - The active color for symbols.
* @customProperty --symbol-size - The size of symbols.
* @customProperty --symbol-spacing - The spacing to use around symbols.
*/
@customElement('sl-rating')
export default class SlRating extends LitElement {
@@ -57,8 +62,9 @@ export default class SlRating extends LitElement {
this.rating.blur();
}
firstUpdated() {
focusVisible.observe(this.rating);
connectedCallback() {
super.connectedCallback();
this.updateComplete.then(() => focusVisible.observe(this.rating));
}
disconnectedCallback() {
@@ -157,7 +163,7 @@ export default class SlRating extends LitElement {
event.preventDefault();
}
@watch('value')
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.slChange.emit();
}

View File

@@ -1,5 +1,5 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators';
import { customElement, property, state } from 'lit/decorators.js';
import { watch } from '../../internal/decorators';
/**
@@ -32,10 +32,6 @@ export default class SlRelativeTime extends LitElement {
/** Keep the displayed value up to date as time passes. */
@property({ type: Boolean }) sync = false;
firstUpdated() {
this.updateTime();
}
disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this.updateTimeout);

View File

@@ -1,5 +1,5 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators';
import { customElement } from 'lit/decorators.js';
import { event, EventEmitter } from '../../internal/decorators';
import styles from 'sass:./resize-observer.scss';

View File

@@ -1,19 +0,0 @@
@use '../../styles/component';
:host {
display: block;
}
.responsive-embed {
position: relative;
::slotted(embed),
::slotted(iframe),
::slotted(object) {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
}
}

View File

@@ -1,46 +0,0 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import styles from 'sass:./responsive-embed.scss';
import { watch } from '../../internal/decorators';
/**
* @since 2.0
* @status stable
*
* @part base - The component's base wrapper.
*/
@customElement('sl-responsive-embed')
export default class SlResponsiveEmbed extends LitElement {
static styles = unsafeCSS(styles);
@query('.responsive-embed') base: HTMLElement;
/**
* The aspect ratio of the embedded media in the format of `width:height`, e.g. `16:9`, `4:3`, or `1:1`. Ratios not in
* this format will be ignored.
*/
@property({ attribute: 'aspect-ratio' }) aspectRatio = '16:9';
@watch('aspectRatio')
updateAspectRatio() {
const split = this.aspectRatio.split(':');
const x = parseInt(split[0]);
const y = parseInt(split[1]);
this.base.style.paddingBottom = x && y ? `${(y / x) * 100}%` : '';
}
render() {
return html`
<div part="base" class="responsive-embed">
<slot @slotchange=${() => this.updateAspectRatio()}></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-responsive-embed': SlResponsiveEmbed;
}
}

View File

@@ -0,0 +1,35 @@
@use '../../styles/component';
:host {
display: block;
}
.responsive-media {
position: relative;
::slotted(*) {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
}
}
.responsive-media--cover {
::slotted(embed),
::slotted(iframe),
::slotted(img),
::slotted(video) {
object-fit: cover !important;
}
}
.responsive-media--contain {
::slotted(embed),
::slotted(iframe),
::slotted(img),
::slotted(video) {
object-fit: contain !important;
}
}

View File

@@ -0,0 +1,50 @@
import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import styles from 'sass:./responsive-media.scss';
/**
* @since 2.0
* @status stable
*
* @slot - The element to receive the aspect ratio. Should be a replaced element, such as `<img>`, `<iframe>`, or `<video>`.
*/
@customElement('sl-responsive-media')
export default class SlResponsiveMedia extends LitElement {
static styles = unsafeCSS(styles);
/**
* The aspect ratio of the embedded media in the format of `width:height`, e.g. `16:9`, `4:3`, or `1:1`. Ratios not in
* this format will be ignored.
*/
@property({ attribute: 'aspect-ratio' }) aspectRatio = '16:9';
/** Determines how content will be resized to fit its container. */
@property() fit: 'cover' | 'contain' = 'cover';
render() {
const split = this.aspectRatio.split(':');
const x = parseInt(split[0]);
const y = parseInt(split[1]);
const paddingBottom = x && y ? `${(y / x) * 100}%` : '0';
return html`
<div
class=${classMap({
'responsive-media': true,
'responsive-media--cover': this.fit === 'cover',
'responsive-media--contain': this.fit === 'contain'
})}
style="padding-bottom: ${paddingBottom}"
>
<slot></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-responsive-media': SlResponsiveMedia;
}
}

View File

@@ -2,9 +2,6 @@
@use '../../styles/form-control';
@use '../../styles/mixins/hide';
/**
* @prop --focus-ring: The focus ring style to use when the control receives focus, a `box-shadow` property.
*/
:host {
--focus-ring: 0 0 0 var(--sl-focus-ring-width) var(--sl-focus-ring-color-primary);
@@ -38,7 +35,7 @@
color: var(--sl-input-color-hover);
}
.select:not(.select--disabled) .select__box:focus {
.select.select--focused:not(.select--disabled) .select__box {
background-color: var(--sl-input-background-color-focus);
border-color: var(--sl-input-border-color-focus);
box-shadow: var(--focus-ring);

View File

@@ -0,0 +1,24 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlSelect from './select';
describe('<sl-select>', () => {
it('should emit sl-change when the value changes', async () => {
const el = (await fixture(html`
<sl-select>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`)) as SlSelect;
const changeHandler = sinon.spy();
el.addEventListener('sl-change', changeHandler);
el.value = 'option-2';
await waitUntil(() => changeHandler.calledOnce);
expect(changeHandler).to.have.been.calledOnce;
});
});

View File

@@ -1,13 +1,16 @@
import { LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property, query, state } from 'lit/decorators';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./select.scss';
import { SlDropdown, SlIconButton, SlMenu, SlMenuItem } from '../../shoelace';
import { getLabelledBy, renderFormControl } from '../../internal/form-control';
import { getTextContent } from '../../internal/slot';
import { hasSlot } from '../../internal/slot';
import type SlDropdown from '../dropdown/dropdown';
import type SlIconButton from '../icon-button/icon-button';
import type SlMenu from '../menu/menu';
import type SlMenuItem from '../menu-item/menu-item';
import styles from 'sass:./select.scss';
let id = 0;
@@ -34,12 +37,15 @@ let id = 0;
* @part menu - The select menu, a <sl-menu> element.
* @part tag - The multiselect option, a <sl-tag> element.
* @part tags - The container in which multiselect options are rendered.
*
* @customProperty --focus-ring - The focus ring style to use when the control receives focus, a `box-shadow` property.
*/
@customElement('sl-select')
export default class SlSelect extends LitElement {
static styles = unsafeCSS(styles);
@query('.select') dropdown: SlDropdown;
@query('.select__box') box: SlDropdown;
@query('.select__hidden-select') input: HTMLInputElement;
@query('.select__menu') menu: SlMenu;
@@ -118,18 +124,22 @@ export default class SlSelect extends LitElement {
connectedCallback() {
super.connectedCallback();
this.handleSlotChange = this.handleSlotChange.bind(this);
this.resizeObserver = new ResizeObserver(() => this.resizeMenu());
this.shadowRoot!.addEventListener('slotchange', this.handleSlotChange);
this.handleSlotChange();
this.updateComplete.then(() => {
this.resizeObserver.observe(this);
this.shadowRoot!.addEventListener('slotchange', this.handleSlotChange);
this.syncItemsFromValue();
});
}
firstUpdated() {
this.resizeObserver = new ResizeObserver(() => this.resizeMenu());
this.syncItemsFromValue();
this.invalid = !this.input.checkValidity();
}
disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver.unobserve(this);
this.shadowRoot!.removeEventListener('slotchange', this.handleSlotChange);
}
@@ -154,12 +164,20 @@ export default class SlSelect extends LitElement {
}
getValueAsArray() {
// Single selects use '' as an empty selection value, so convert this to [] for an empty multi select
if (this.multiple && this.value === '') {
return [];
}
return Array.isArray(this.value) ? this.value : [this.value];
}
handleBlur() {
this.hasFocus = false;
this.slBlur.emit();
// Don't blur if the control is open. We'll move focus back once it closes.
if (!this.isOpen) {
this.hasFocus = false;
this.slBlur.emit();
}
}
handleClearClick(event: MouseEvent) {
@@ -169,7 +187,7 @@ export default class SlSelect extends LitElement {
this.syncItemsFromValue();
}
@watch('disabled')
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
if (this.disabled && this.isOpen) {
this.dropdown.hide();
@@ -177,8 +195,10 @@ export default class SlSelect extends LitElement {
}
handleFocus() {
this.hasFocus = true;
this.slFocus.emit();
if (!this.hasFocus) {
this.hasFocus = true;
this.slFocus.emit();
}
}
handleKeyDown(event: KeyboardEvent) {
@@ -221,8 +241,8 @@ export default class SlSelect extends LitElement {
}
}
// All other keys open the menu and initiate type to select
if (!this.isOpen) {
// All other "printable" keys open the menu and initiate type to select
if (!this.isOpen && event.key.length === 1) {
event.stopPropagation();
event.preventDefault();
this.dropdown.show();
@@ -249,23 +269,19 @@ export default class SlSelect extends LitElement {
this.syncItemsFromValue();
}
handleMenuShow(event: CustomEvent) {
if (this.disabled) {
event.preventDefault();
return;
}
handleMenuShow() {
this.resizeMenu();
this.resizeObserver.observe(this);
this.isOpen = true;
}
handleMenuHide() {
this.resizeObserver.unobserve(this);
this.isOpen = false;
// Restore focus on the box after the menu is hidden
this.box.focus();
}
@watch('multiple')
@watch('multiple', { waitUntilFirstUpdate: true })
handleMultipleChange() {
// Cast to array | string based on `this.multiple`
const value = this.getValueAsArray();
@@ -273,8 +289,8 @@ export default class SlSelect extends LitElement {
this.syncItemsFromValue();
}
@watch('helpText')
@watch('label')
@watch('helpText', { waitUntilFirstUpdate: true })
@watch('label', { waitUntilFirstUpdate: true })
async handleSlotChange() {
this.hasHelpTextSlot = hasSlot(this, 'help-text');
this.hasLabelSlot = hasSlot(this, 'label');
@@ -300,9 +316,11 @@ export default class SlSelect extends LitElement {
}
}
@watch('value')
handleValueChange() {
@watch('value', { waitUntilFirstUpdate: true })
async handleValueChange() {
this.syncItemsFromValue();
await this.updateComplete;
this.invalid = !this.input.checkValidity();
this.slChange.emit();
}
@@ -399,6 +417,7 @@ export default class SlSelect extends LitElement {
.hoist=${this.hoist}
.closeOnSelect=${!this.multiple}
.containingElement=${this}
?disabled=${this.disabled}
class=${classMap({
select: true,
'select--open': this.isOpen,

Some files were not shown because too many files have changed in this diff Show More