mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-15 13:39:14 +00:00
Compare commits
42 Commits
menu-item-
...
dropdown-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c883d4e59 | ||
|
|
75b2da9eab | ||
|
|
9736f053d9 | ||
|
|
d0b710c26d | ||
|
|
5b83d4d1b0 | ||
|
|
89f0f4a02c | ||
|
|
a067ccb9e0 | ||
|
|
1ccea42cca | ||
|
|
0f90dd0f54 | ||
|
|
262cbc9a22 | ||
|
|
3a61d20d93 | ||
|
|
95f4f87eb8 | ||
|
|
5b3cc0d492 | ||
|
|
0de39a8163 | ||
|
|
879fd7a224 | ||
|
|
50af138424 | ||
|
|
9d592f4e08 | ||
|
|
5016d27af7 | ||
|
|
7218a19357 | ||
|
|
33d2d4368f | ||
|
|
cca40ca710 | ||
|
|
c6281859fd | ||
|
|
956271880d | ||
|
|
201ff4efc5 | ||
|
|
f954233bda | ||
|
|
8267968b76 | ||
|
|
1e243e4257 | ||
|
|
0b6c3a46cf | ||
|
|
a2e58b7696 | ||
|
|
119d299657 | ||
|
|
e8634e4178 | ||
|
|
2e2a683d11 | ||
|
|
414197acc9 | ||
|
|
8fd01e1eda | ||
|
|
e1ca7d1f59 | ||
|
|
f84d6939bd | ||
|
|
82446e2114 | ||
|
|
a4f0ae9088 | ||
|
|
fe3906f766 | ||
|
|
c9e644f3fc | ||
|
|
8ffbd02db7 | ||
|
|
e88d57d17d |
@@ -92,7 +92,8 @@ module.exports = {
|
||||
'@typescript-eslint/member-delimiter-style': 'warn',
|
||||
'@typescript-eslint/method-signature-style': 'warn',
|
||||
'@typescript-eslint/no-extraneous-class': 'error',
|
||||
'@typescript-eslint/no-parameter-properties': 'error',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'off',
|
||||
'@typescript-eslint/parameter-properties': 'error',
|
||||
'@typescript-eslint/strict-boolean-expressions': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"minlength",
|
||||
"monospace",
|
||||
"mousedown",
|
||||
"mousemove",
|
||||
"mouseup",
|
||||
"multiselectable",
|
||||
"nextjs",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parse } from 'comment-parser';
|
||||
import { pascalCase } from 'pascal-case';
|
||||
import commandLineArgs from 'command-line-args';
|
||||
import fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const packageData = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
|
||||
const { name, description, version, author, homepage, license } = packageData;
|
||||
@@ -26,7 +27,7 @@ function replace(string, terms) {
|
||||
}
|
||||
|
||||
export default {
|
||||
globs: ['src/components/**/*.ts'],
|
||||
globs: ['src/components/**/*.component.ts'],
|
||||
exclude: ['**/*.styles.ts', '**/*.test.ts'],
|
||||
plugins: [
|
||||
// Append package data
|
||||
@@ -36,7 +37,32 @@ export default {
|
||||
customElementsManifest.package = { name, description, version, author, homepage, license };
|
||||
}
|
||||
},
|
||||
// Infer tag names because we no longer use @customElement decorators.
|
||||
{
|
||||
name: 'shoelace-infer-tag-names',
|
||||
analyzePhase({ ts, node, moduleDoc }) {
|
||||
switch (node.kind) {
|
||||
case ts.SyntaxKind.ClassDeclaration: {
|
||||
const className = node.name.getText();
|
||||
const classDoc = moduleDoc?.declarations?.find(declaration => declaration.name === className);
|
||||
|
||||
const importPath = moduleDoc.path;
|
||||
|
||||
// This is kind of a best guess at components. "thing.component.ts"
|
||||
if (!importPath.endsWith('.component.ts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = 'sl-' + path.basename(importPath, '.component.ts');
|
||||
|
||||
classDoc.tagName = tagName;
|
||||
|
||||
// This used to be set to true by @customElement
|
||||
classDoc.customElement = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Parse custom jsDoc tags
|
||||
{
|
||||
name: 'shoelace-custom-tags',
|
||||
@@ -58,6 +84,9 @@ export default {
|
||||
});
|
||||
});
|
||||
|
||||
// This is what allows us to map JSDOC comments to ReactWrappers.
|
||||
classDoc['jsDoc'] = node.jsDoc?.map(jsDoc => jsDoc.getFullText()).join('\n');
|
||||
|
||||
const parsed = parse(`${customComments}\n */`);
|
||||
parsed[0].tags?.forEach(t => {
|
||||
switch (t.tag) {
|
||||
@@ -116,6 +145,7 @@ export default {
|
||||
if (classDoc?.events) {
|
||||
classDoc.events.forEach(event => {
|
||||
event.reactName = `on${pascalCase(event.name)}`;
|
||||
event.eventName = `${pascalCase(event.name)}Event`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,15 +58,15 @@
|
||||
const clearButton = siteSearch.querySelector('.search__clear-button');
|
||||
const results = siteSearch.querySelector('.search__results');
|
||||
const version = document.documentElement.getAttribute('data-shoelace-version');
|
||||
const animationDuration = 150;
|
||||
const key = `search_${version}`;
|
||||
const searchDebounce = 50;
|
||||
const animationDuration = 150;
|
||||
let isShowing = false;
|
||||
let searchTimeout;
|
||||
let searchIndex;
|
||||
let map;
|
||||
|
||||
const loadSearchIndex = new Promise(resolve => {
|
||||
const key = `search_${version}`;
|
||||
const cache = localStorage.getItem(key);
|
||||
const wait = 'requestIdleCallback' in window ? requestIdleCallback : requestAnimationFrame;
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
const a = document.createElement('a');
|
||||
const displayTitle = page.title ?? '';
|
||||
const displayDescription = page.description ?? '';
|
||||
const displayUrl = page.url.replace(/^\//, '');
|
||||
const displayUrl = page.url.replace(/^\//, '').replace(/\/$/, '');
|
||||
let icon = 'file-text';
|
||||
|
||||
a.setAttribute('role', 'option');
|
||||
@@ -357,6 +357,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Purge cache when we press CMD+CTRL+R
|
||||
document.addEventListener('keydown', event => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'r') {
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', handleInput);
|
||||
clearButton.addEventListener('click', handleClear);
|
||||
|
||||
|
||||
@@ -180,7 +180,10 @@ p {
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--docs-border-radius);
|
||||
}
|
||||
|
||||
.badges img {
|
||||
border-radius: var(--sl-border-radius-medium);
|
||||
}
|
||||
|
||||
.callout img,
|
||||
@@ -234,6 +237,7 @@ kbd {
|
||||
border: solid 1px var(--sl-color-neutral-200);
|
||||
box-shadow: inset 0 1px 0 0 var(--sl-color-neutral-0), inset 0 -1px 0 0 var(--sl-color-neutral-200);
|
||||
font-family: var(--sl-font-mono);
|
||||
font-size: 0.9125em;
|
||||
border-radius: var(--docs-border-radius);
|
||||
color: var(--sl-color-neutral-800);
|
||||
padding: 0.125em 0.4em;
|
||||
|
||||
@@ -171,7 +171,10 @@ module.exports = function (eleventyConfig) {
|
||||
this.field('c'); // content
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const url = path.join('/', path.relative(eleventyConfig.dir.output, result.outputPath)).replace(/\\/g, '/');
|
||||
const url = path
|
||||
.join('/', path.relative(eleventyConfig.dir.output, result.outputPath))
|
||||
.replace(/\\/g, '/') // convert backslashes to forward slashes
|
||||
.replace(/\/index.html$/, '/'); // convert trailing /index.html to /
|
||||
const doc = new JSDOM(result.content, {
|
||||
// We must set a default URL so links are parsed with a hostname. Let's use a bogus TLD so we can easily
|
||||
// identify which ones are internal and which ones are external.
|
||||
|
||||
@@ -455,7 +455,7 @@ const App = () => (
|
||||
|
||||
### Disabled
|
||||
|
||||
Use the `disabled` attribute to disable a button. Clicks will be suppressed until the disabled state is removed.
|
||||
Use the `disabled` attribute to disable a button.
|
||||
|
||||
```html:preview
|
||||
<sl-button variant="default" disabled>Default</sl-button>
|
||||
|
||||
@@ -7,7 +7,7 @@ layout: component
|
||||
|
||||
Dropdowns consist of a trigger and a panel. By default, activating the trigger will expose the panel and interacting outside of the panel will close it.
|
||||
|
||||
Dropdowns are designed to work well with [menus](/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.
|
||||
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)). The API gives you complete control over showing, hiding, and positioning the panel.
|
||||
|
||||
```html:preview
|
||||
<sl-dropdown>
|
||||
|
||||
@@ -8,7 +8,7 @@ layout: component
|
||||
Shoelace comes bundled with over 1,500 icons courtesy of the [Bootstrap Icons](https://icons.getbootstrap.com/) project. These icons are part of the `default` icon library. If you prefer, you can register [custom icon libraries](#icon-libraries) as well.
|
||||
|
||||
:::tip
|
||||
Depending on how you're loading Shoelace, you may need to copy icon assets and/or [set the base path](getting-started/installation#setting-the-base-path) so Shoelace knows where to load them from. Otherwise, icons may not appear and you'll see 404 Not Found errors in the dev console.
|
||||
Depending on how you're loading Shoelace, you may need to copy icon assets and/or [set the base path](/getting-started/installation/#setting-the-base-path) so Shoelace knows where to load them from. Otherwise, icons may not appear and you'll see 404 Not Found errors in the dev console.
|
||||
:::
|
||||
|
||||
## Default Icons
|
||||
|
||||
@@ -1511,3 +1511,178 @@ const App = () => {
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Virtual Elements
|
||||
|
||||
In most cases, popups are anchored to an actual element. Sometimes, it can be useful to anchor them to a non-element. To do this, you can pass a `VirtualElement` to the anchor property. A virtual element must contain a function called `getBoundingClientRect()` that returns a [`DOMRect`](https://developer.mozilla.org/en-US/docs/Web/API/DOMRect) object as shown below.
|
||||
|
||||
```ts
|
||||
const virtualElement = {
|
||||
getBoundingClientRect() {
|
||||
// ...
|
||||
return { width, height, x, y, top, left, right, bottom };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This example anchors a popup to the mouse cursor using a virtual element. As such, a mouse is required to properly view it.
|
||||
|
||||
```html:preview
|
||||
<div class="popup-virtual-element">
|
||||
<sl-popup placement="right-start">
|
||||
<div class="circle"></div>
|
||||
</sl-popup>
|
||||
|
||||
<sl-switch>Highlight mouse cursor</sl-switch>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const container = document.querySelector('.popup-virtual-element');
|
||||
const popup = container.querySelector('sl-popup');
|
||||
const circle = container.querySelector('.circle');
|
||||
const enabled = container.querySelector('sl-switch');
|
||||
let clientX = 0;
|
||||
let clientY = 0;
|
||||
|
||||
// Set the virtual element as a property
|
||||
popup.anchor = {
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
right: clientX,
|
||||
bottom: clientY
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Only activate the popup when the switch is checked
|
||||
enabled.addEventListener('sl-change', () => {
|
||||
popup.active = enabled.checked;
|
||||
});
|
||||
|
||||
// Listen for the mouse to move
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
// Update the virtual element as the mouse moves
|
||||
function handleMouseMove(event) {
|
||||
clientX = event.clientX;
|
||||
clientY = event.clientY;
|
||||
|
||||
// Reposition the popup when the virtual anchor moves
|
||||
if (popup.active) {
|
||||
popup.reposition();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* If you need to set a z-index, set it on the popup part like this */
|
||||
.popup-virtual-element sl-popup::part(popup) {
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.popup-virtual-element .circle {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: solid 4px var(--sl-color-primary-600);
|
||||
border-radius: 50%;
|
||||
translate: -50px -50px;
|
||||
animation: 1s virtual-cursor infinite;
|
||||
}
|
||||
|
||||
@keyframes virtual-cursor {
|
||||
0% { scale: 1; }
|
||||
50% { scale: 1.1; }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
```jsx:react
|
||||
import { useRef, useState } from 'react';
|
||||
import { SlPopup, SlSwitch } from '@shoelace-style/shoelace/dist/react';
|
||||
|
||||
const css = `
|
||||
/* If you need to set a z-index, set it on the popup part like this */
|
||||
.popup-virtual-element sl-popup::part(popup) {
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.popup-virtual-element .circle {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: solid 4px var(--sl-color-primary-600);
|
||||
border-radius: 50%;
|
||||
translate: -50px -50px;
|
||||
animation: 1s virtual-cursor infinite;
|
||||
}
|
||||
|
||||
@keyframes virtual-cursor {
|
||||
0% { scale: 1; }
|
||||
50% { scale: 1.1; }
|
||||
}
|
||||
`;
|
||||
|
||||
const App = () => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [clientX, setClientX] = useState(0);
|
||||
const [clientY, setClientY] = useState(0);
|
||||
const popup = useRef(null);
|
||||
const circle = useRef(null);
|
||||
const virtualElement = {
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
right: clientX,
|
||||
bottom: clientY
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for the mouse to move
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
// Update the virtual element as the mouse moves
|
||||
function handleMouseMove(event) {
|
||||
setClientX(event.clientX);
|
||||
setClientY(event.clientY);
|
||||
|
||||
// Reposition the popup when the virtual anchor moves
|
||||
if (popup.active) {
|
||||
popup.current.reposition();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="popup-virtual-element">
|
||||
<SlPopup
|
||||
ref={popup}
|
||||
placement="right-start"
|
||||
active={enabled}
|
||||
anchor={virtualElement}
|
||||
>
|
||||
<div ref={circle} className="circle" />
|
||||
</SlPopup>
|
||||
|
||||
<SlSwitch checked={enabled} onSlChange={event => setEnabled(event.target.checked)}>
|
||||
Highlight mouse cursor
|
||||
</SlSwitch>
|
||||
</div>
|
||||
|
||||
<style>{css}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -9,13 +9,13 @@ layout: component
|
||||
<sl-split-panel>
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
@@ -69,13 +69,13 @@ To set the initial position, use the `position` attribute. If no position is pro
|
||||
<sl-split-panel position="75">
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
@@ -90,13 +90,13 @@ To set the initial position in pixels instead of a percentage, use the `position
|
||||
<sl-split-panel position-in-pixels="150">
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
@@ -148,13 +148,13 @@ Add the `vertical` attribute to render the split panel in a vertical orientation
|
||||
<sl-split-panel vertical style="height: 400px;">
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
@@ -207,13 +207,13 @@ To snap panels at specific positions while dragging, add the `snap` attribute wi
|
||||
<sl-split-panel snap="100px 50%">
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
@@ -328,13 +328,13 @@ Add the `disabled` attribute to prevent the divider from being repositioned.
|
||||
<sl-split-panel disabled>
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
@@ -389,13 +389,13 @@ Try resizing the example below with each option and notice how the panels respon
|
||||
<sl-split-panel>
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
@@ -482,13 +482,13 @@ This examples demonstrates how you can ensure both panels are at least 150px usi
|
||||
<sl-split-panel style="--min: 150px; --max: calc(100% - 150px);">
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
@@ -540,7 +540,7 @@ Create complex layouts that can be repositioned independently by nesting split p
|
||||
<sl-split-panel>
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 400px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 400px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
@@ -548,13 +548,13 @@ Create complex layouts that can be repositioned independently by nesting split p
|
||||
<sl-split-panel vertical style="height: 400px;">
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden"
|
||||
>
|
||||
Top
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 100%; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden"
|
||||
>
|
||||
Bottom
|
||||
</div>
|
||||
@@ -625,13 +625,13 @@ You can target the `divider` part to apply CSS properties to the divider. To add
|
||||
<sl-icon slot="divider" name="grip-vertical"></sl-icon>
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
@@ -684,13 +684,13 @@ Here's a more elaborate example that changes the divider's color and width and a
|
||||
<sl-icon slot="divider" name="grip-vertical"></sl-icon>
|
||||
<div
|
||||
slot="start"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
Start
|
||||
</div>
|
||||
<div
|
||||
slot="end"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center;"
|
||||
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
|
||||
>
|
||||
End
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,25 @@ function MyComponent() {
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
You can also import the event type for use in your callbacks, shown below.
|
||||
|
||||
```tsx
|
||||
import { useCallback, useState } from 'react';
|
||||
import { SlInput, SlInputEvent } from '@shoelace-style/shoelace/%NPMDIR%/react';
|
||||
import type SlInputElement from '@shoelace-style/shoelace/%NPMDIR%/components/input/input';
|
||||
|
||||
function MyComponent() {
|
||||
const [value, setValue] = useState('');
|
||||
const onInput = useCallback((event: SlInputEvent) => {
|
||||
setValue(event.detail);
|
||||
}, []);
|
||||
|
||||
return <SlInput value={value} onSlInput={event => setValue((event.target as SlInputElement).value)} />;
|
||||
}
|
||||
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
## Testing with Jest
|
||||
|
||||
Testing with web components can be challenging if your test environment runs in a Node environment (i.e. it doesn't run in a real browser). Fortunately, [Jest](https://jestjs.io/) has made a number of strides to support web components and provide additional browser APIs. However, it's still not a complete replication of a browser environment.
|
||||
|
||||
@@ -6,13 +6,13 @@ meta:
|
||||
|
||||
# Customizing
|
||||
|
||||
Shoelace components can be customized at a high level through design tokens. This gives you control over theme colors and general styling. For more advanced customizations, you can make use of component parts and custom properties to target individual components.
|
||||
Shoelace components can be customized at a high level through design tokens. This gives you control over theme colors and general styling. For more advanced customizations, you can make use of CSS parts and custom properties to target individual components.
|
||||
|
||||
## Design Tokens
|
||||
|
||||
Shoelace makes use of several design tokens to provide a consistent appearance across components. You can customize them and use them in your own application with pure CSS — no preprocessor required.
|
||||
|
||||
Design tokens offer a high-level way to customize the library with minimal effort. There are no component-specific variables, however, as design tokens are intended to be generic and highly reusable. To customize an individual component, refer to the section entitled [Component Parts](#component-parts).
|
||||
Design tokens offer a high-level way to customize the library with minimal effort. There are no component-specific variables, however, as design tokens are intended to be generic and highly reusable. To customize an individual component, refer to the section entitled [CSS Parts](#css-parts).
|
||||
|
||||
Design tokens are accessed through CSS custom properties that are defined in your theme. Because design tokens live at the page level, they're prefixed with `--sl-` to avoid collisions with other libraries.
|
||||
|
||||
@@ -37,9 +37,9 @@ To customize a design token, simply override it in your stylesheet using a `:roo
|
||||
|
||||
Many design tokens are described further along in this documentation. For a complete list, refer to `src/themes/light.css` in the project's [source code](https://github.com/shoelace-style/shoelace/blob/current/src/themes/light.css).
|
||||
|
||||
## Component Parts
|
||||
## CSS Parts
|
||||
|
||||
Whereas design tokens offer a high-level way to customize the library, component parts offer a low-level way to customize individual components. Again, this is done with pure CSS — no preprocessor required.
|
||||
Whereas design tokens offer a high-level way to customize the library, CSS parts offer a low-level way to customize individual components. Again, this is done with pure CSS — no preprocessor required.
|
||||
|
||||
Shoelace components use a [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM) to encapsulate their styles and behaviors. As a result, you can't simply target their internals with the usual CSS selectors. Instead, components expose "parts" that can be targeted with the [CSS part selector](https://developer.mozilla.org/en-US/docs/Web/CSS/::part), or `::part()`.
|
||||
|
||||
@@ -76,7 +76,7 @@ At first glance, this approach might seem a bit verbose or even limiting, but it
|
||||
|
||||
- Customizations can be made to components with explicit selectors, such as `::part(icon)`, rather than implicit selectors, such as `.button > div > span + .icon`, that are much more fragile.
|
||||
|
||||
- The internal structure of a component will likely change as it evolves. By exposing component parts through an API, the internals can be reworked without fear of breaking customizations as long as its parts remain intact.
|
||||
- The internal structure of a component will likely change as it evolves. By exposing CSS parts through an API, the internals can be reworked without fear of breaking customizations as long as its parts remain intact.
|
||||
|
||||
- It encourages us to think more about how components are designed and how customizations should be allowed before users can take advantage of them. Once we opt a part into the component's API, it's guaranteed to be supported and can't be removed until a major version of the library is released.
|
||||
|
||||
|
||||
@@ -80,9 +80,20 @@ The form will not be submitted if a required field is incomplete.
|
||||
|
||||
<script type="module">
|
||||
const form = document.querySelector('.input-validation-required');
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
|
||||
// Wait for controls to be defined before attaching form listeners
|
||||
await Promise.all([
|
||||
customElements.whenDefined('sl-button'),
|
||||
customElements.whenDefined('sl-checkbox'),
|
||||
customElements.whenDefined('sl-input'),
|
||||
customElements.whenDefined('sl-option'),
|
||||
customElements.whenDefined('sl-select'),
|
||||
customElements.whenDefined('sl-textarea')
|
||||
]).then(() => {
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
@@ -134,9 +145,16 @@ To restrict a value to a specific [pattern](https://developer.mozilla.org/en-US/
|
||||
|
||||
<script type="module">
|
||||
const form = document.querySelector('.input-validation-pattern');
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
|
||||
// Wait for controls to be defined before attaching form listeners
|
||||
await Promise.all([
|
||||
customElements.whenDefined('sl-button'),
|
||||
customElements.whenDefined('sl-input')
|
||||
]).then(() => {
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
@@ -178,9 +196,16 @@ Some input types will automatically trigger constraints, such as `email` and `ur
|
||||
|
||||
<script type="module">
|
||||
const form = document.querySelector('.input-validation-type');
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
|
||||
// Wait for controls to be defined before attaching form listeners
|
||||
await Promise.all([
|
||||
customElements.whenDefined('sl-button'),
|
||||
customElements.whenDefined('sl-input')
|
||||
]).then(() => {
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
@@ -224,17 +249,23 @@ To create a custom validation error, pass a non-empty string to the `setCustomVa
|
||||
const form = document.querySelector('.input-validation-custom');
|
||||
const input = form.querySelector('sl-input');
|
||||
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
});
|
||||
// Wait for controls to be defined before attaching form listeners
|
||||
await Promise.all([
|
||||
customElements.whenDefined('sl-button'),
|
||||
customElements.whenDefined('sl-input')
|
||||
]).then(() => {
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
});
|
||||
|
||||
input.addEventListener('sl-input', () => {
|
||||
if (input.value === 'shoelace') {
|
||||
input.setCustomValidity('');
|
||||
} else {
|
||||
input.setCustomValidity("Hey, you're supposed to type 'shoelace' before submitting this!");
|
||||
}
|
||||
input.addEventListener('sl-input', () => {
|
||||
if (input.value === 'shoelace') {
|
||||
input.setCustomValidity('');
|
||||
} else {
|
||||
input.setCustomValidity("Hey, you're supposed to type 'shoelace' before submitting this!");
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
@@ -326,9 +357,19 @@ This example demonstrates custom validation styles using `data-user-invalid` and
|
||||
|
||||
<script type="module">
|
||||
const form = document.querySelector('.validity-styles');
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
|
||||
// Wait for controls to be defined before attaching form listeners
|
||||
await Promise.all([
|
||||
customElements.whenDefined('sl-button'),
|
||||
customElements.whenDefined('sl-checkbox'),
|
||||
customElements.whenDefined('sl-input'),
|
||||
customElements.whenDefined('sl-option'),
|
||||
customElements.whenDefined('sl-select')
|
||||
]).then(() => {
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
alert('All fields are valid!');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -417,33 +458,39 @@ To disable the browser's error messages, you need to cancel the `sl-invalid` eve
|
||||
const form = document.querySelector('.inline-validation');
|
||||
const nameError = document.querySelector('#name-error');
|
||||
|
||||
// A form control is invalid
|
||||
form.addEventListener(
|
||||
'sl-invalid',
|
||||
event => {
|
||||
// Suppress the browser's constraint validation message
|
||||
// Wait for controls to be defined before attaching form listeners
|
||||
await Promise.all([
|
||||
customElements.whenDefined('sl-button'),
|
||||
customElements.whenDefined('sl-input')
|
||||
]).then(() => {
|
||||
// A form control is invalid
|
||||
form.addEventListener(
|
||||
'sl-invalid',
|
||||
event => {
|
||||
// Suppress the browser's constraint validation message
|
||||
event.preventDefault();
|
||||
|
||||
nameError.textContent = `Error: ${event.target.validationMessage}`;
|
||||
nameError.hidden = false;
|
||||
|
||||
event.target.focus();
|
||||
},
|
||||
{ capture: true } // you must use capture since sl-invalid doesn't bubble!
|
||||
);
|
||||
|
||||
// Handle form submit
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
nameError.hidden = true;
|
||||
nameError.textContent = '';
|
||||
setTimeout(() => alert('All fields are valid'), 50);
|
||||
});
|
||||
|
||||
nameError.textContent = `Error: ${event.target.validationMessage}`;
|
||||
nameError.hidden = false;
|
||||
|
||||
event.target.focus();
|
||||
},
|
||||
{ capture: true } // you must use capture since sl-invalid doesn't bubble!
|
||||
);
|
||||
|
||||
// Handle form submit
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
nameError.hidden = true;
|
||||
nameError.textContent = '';
|
||||
setTimeout(() => alert('All fields are valid'), 50);
|
||||
});
|
||||
|
||||
// Handle form reset
|
||||
form.addEventListener('reset', event => {
|
||||
nameError.hidden = true;
|
||||
nameError.textContent = '';
|
||||
// Handle form reset
|
||||
form.addEventListener('reset', event => {
|
||||
nameError.hidden = true;
|
||||
nameError.textContent = '';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -112,15 +112,29 @@ However, if you're [cherry picking](#cherry-picking) or [bundling](#bundling) Sh
|
||||
```
|
||||
|
||||
:::tip
|
||||
When setting a basePath, and easy way to check if it was down properly is by checking if an icon exists.
|
||||
|
||||
For example, if I set the basePath to `/dist`, I should be able to go to:
|
||||
|
||||
`https://<my-site>/dist/assets/icons/arrow-left.svg` and the browser should show me the SVG.
|
||||
|
||||
Shoelace also exports a `getBasePath()` method you can use to reference assets.
|
||||
An easy way to make sure the base path is configured properly is to check if [icons](/components/icon) are loading.
|
||||
:::
|
||||
|
||||
### Referencing Assets
|
||||
|
||||
Most of the magic behind assets is handled internally by Shoelace, but if you need to reference the base path for any reason, the same module exports a function called `getBasePath()`. An optional string argument can be passed, allowing you to get the full path to any asset.
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import { getBasePath, setBasePath } from '@shoelace-style/shoelace/%NPMDIR%/utilities/base-path.js';
|
||||
|
||||
setBasePath('/path/to/assets');
|
||||
|
||||
// ...
|
||||
|
||||
// Get the base path, e.g. /path/to/assets
|
||||
const basePath = getBasePath();
|
||||
|
||||
// Get the path to an asset, e.g. /path/to/assets/file.ext
|
||||
const assetPath = getBasePath('file.ext');
|
||||
</script>
|
||||
```
|
||||
|
||||
## Cherry Picking
|
||||
|
||||
Cherry picking can be done from [the CDN](#cdn-installation-easiest) or from [npm](#npm-installation). This approach will load only the components you need up front, while limiting the number of files the browser has to download. The disadvantage is that you need to import each individual component.
|
||||
@@ -182,6 +196,21 @@ setBasePath('/path/to/shoelace/%NPMDIR%
|
||||
Component modules include side effects for registration purposes. Because of this, importing directly from `@shoelace-style/shoelace` may result in a larger bundle size than necessary. For optimal tree shaking, always cherry pick, i.e. import components and utilities from their respective files, as shown above.
|
||||
:::
|
||||
|
||||
### Avoiding side-effect imports
|
||||
|
||||
By default, imports to components will auto-register themselves. This may not be ideal in all cases. To import just the component's class without auto-registering it's tag we can do the following:
|
||||
|
||||
```diff
|
||||
- import SlButton from '@shoelace-style/shoelace/%NPMDIR%/components/button/button.js';
|
||||
+ import SlButton from '@shoelace-style/shoelace/%NPMDIR%/components/button/button.component.js';
|
||||
```
|
||||
|
||||
Notice how the import ends with `.component.js`. This is the current convention to convey the import does not register itself.
|
||||
|
||||
:::danger
|
||||
While you can override the class or re-register the shoelace class under a different tag name, if you do so, many components won’t work as expected.
|
||||
:::
|
||||
|
||||
## The difference between CDN and npm
|
||||
|
||||
You'll notice that the CDN links all start with `/%CDNDIR%/<path>` and npm imports use `/%NPMDIR%/<path>`. The `/%CDNDIR%` files are bundled separately from the `/%NPMDIR%` files. The `/%CDNDIR%` files come pre-bundled, which means all dependencies are inlined so you do not need to worry about loading additional libraries. The `/%NPMDIR%` files **DO NOT** come pre-bundled, allowing your bundler of choice to more efficiently deduplicate dependencies, resulting in smaller bundles and optimal code sharing.
|
||||
|
||||
@@ -27,6 +27,8 @@ toc: false
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="badges">
|
||||
|
||||
[](https://www.jsdelivr.com/package/npm/@shoelace-style/shoelace)
|
||||
[](https://www.npmjs.com/package/@shoelace-style/shoelace)
|
||||
[](https://github.com/shoelace-style/shoelace/blob/next/LICENSE.md)<br>
|
||||
@@ -34,6 +36,8 @@ toc: false
|
||||
[](https://twitter.com/shoelace_style)
|
||||
[](https://github.com/shoelace-style/shoelace)
|
||||
|
||||
</div>
|
||||
|
||||
## Quick Start
|
||||
|
||||
Add the following code to your page.
|
||||
|
||||
@@ -14,15 +14,37 @@ New versions of Shoelace are released as-needed and generally occur when a criti
|
||||
|
||||
## Next
|
||||
|
||||
- Fixed a bug in `<sl-dropdown>` where pressing [[Up]] or [[Down]] when focused on the trigger wouldn't focus the first/last menu items [#1472]
|
||||
|
||||
## 2.6.0
|
||||
|
||||
- Added JSDoc comments to React Wrappers for better documentation when hovering a component. [#1450]
|
||||
- Added `displayName` to React Wrappers for better debugging. [#1450]
|
||||
- Added non-auto-registering routes for Components to fix a number of issues around auto-registration. [#1450]
|
||||
- Added a console warning if you attempt to register the same Shoelace component twice. [#1450]
|
||||
- Added tests for `<sl-qr-code>` [#1416]
|
||||
- Added support for pressing [[Space]] to select/toggle selected `<sl-menu-item>` elements [#1429]
|
||||
- Added support for virtual elements in `<sl-popup>` [#1449]
|
||||
- Added the `spinner` part to `<sl-button>` [#1460]
|
||||
- Added a `shoelace.js` and `shoelace-autoloader.js` to exportmaps. [#1450]
|
||||
- Added types to events emitted by React wrapped components [#1419]
|
||||
- Fixed React component treeshaking by introducing `sideEffects` key in `package.json`. [#1450]
|
||||
- Fixed a bug in `<sl-tree>` where it was auto-defining `<sl-tree-item>`. [#1450]
|
||||
- Fixed a bug in focus trapping of modal elements like `<sl-dialog>`. We now manually handle focus ordering as well as added `offsetParent()` check for tabbable boundaries in Safari. Test cases added for `<sl-dialog>` inside a shadowRoot [#1403]
|
||||
- Fixed a bug in `valueAsDate` on `<sl-input>` where it would always set `type="date"` for the underlying `<input>` element. It now falls back to the native browser implementation for the in-memory input. This may cause unexpected behavior if you're using `valueAsDate` on any input elements that aren't `type="date"`. [#1399]
|
||||
- Fixed a bug in `<sl-qr-code>` where the `background` attribute was never passed to the QR code [#1416]
|
||||
- Fixed a bug in `<sl-dropdown>` where aria attributes were incorrectly applied to the default `<slot>` causing Lighthouse errors [#1417]
|
||||
- Fixed a bug in `<sl-carousel>` that caused navigation to work incorrectly in some case [#1420]
|
||||
- Fixed a number of slots that incorrectly had aria- and/or role attributes directly on them [#1422]
|
||||
- Fixed a bug in `<sl-tree>` that caused focus to be stolen when removing focused tree items [#1430]
|
||||
- Fixed a bug in `<sl-dialog>` and `<sl-drawer>` that caused nested modals to respond too eagerly to the [[Esc]] key [#1457]
|
||||
- Improved `<sl-details>` to use `<details>` internally for better semantics and to enable search to find in supportive browsers when collapsed [#1470]
|
||||
- Updated ESLint and related plugins to the latest versions
|
||||
- Changed the default entrypoint for jsDelivr to point to the autoloader. [#1450]
|
||||
|
||||
## 2.5.2
|
||||
|
||||
- Fixed broken source buttons in the docs [#1401]
|
||||
- Fixed broken links in the docs [#1407]
|
||||
|
||||
## 2.5.1
|
||||
|
||||
|
||||
@@ -367,7 +367,6 @@ Then use the following syntax for comments so they appear in the generated docs.
|
||||
* @cssproperty --color: The component's text color.
|
||||
* @cssproperty --background-color: The component's background color.
|
||||
*/
|
||||
@customElement('sl-example')
|
||||
export default class SlExample {
|
||||
// ...
|
||||
}
|
||||
|
||||
@@ -18,146 +18,146 @@ Currently, the source of design tokens is considered to be [`light.css`](https:/
|
||||
|
||||
Focus ring tokens control the appearance of focus rings. Note that form inputs use `--sl-input-focus-ring-*` tokens instead.
|
||||
|
||||
| Token | Value |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------- |
|
||||
| `--sl-focus-ring-color` | var(--sl-color-primary-600) (light theme)<br>var(--sl-color-primary-700) (dark theme) |
|
||||
| `--sl-focus-ring-style` | solid |
|
||||
| `--sl-focus-ring-width` | 3px |
|
||||
| `--sl-focus-ring` | var(--sl-focus-ring-style) var(--sl-focus-ring-width) var(--sl-focus-ring-color) |
|
||||
| `--sl-focus-ring-offset` | 1px |
|
||||
| Token | Value |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| `--sl-focus-ring-color` | `var(--sl-color-primary-600)` (light theme)<br>`var(--sl-color-primary-700)` (dark theme) |
|
||||
| `--sl-focus-ring-style` | `solid` |
|
||||
| `--sl-focus-ring-width` | `3px` |
|
||||
| `--sl-focus-ring` | `var(--sl-focus-ring-style) var(--sl-focus-ring-width) var(--sl-focus-ring-color)` |
|
||||
| `--sl-focus-ring-offset` | `1px` |
|
||||
|
||||
## Buttons
|
||||
|
||||
Button tokens control the appearance of buttons. In addition, buttons also currently use some form input tokens such as `--sl-input-height-*` and `--sl-input-border-*`. More button tokens may be added in the future to make it easier to style them more independently.
|
||||
|
||||
| Token | Value |
|
||||
| ------------------------------ | --------------------------- |
|
||||
| `--sl-button-font-size-small` | var(--sl-font-size-x-small) |
|
||||
| `--sl-button-font-size-medium` | var(--sl-font-size-small) |
|
||||
| `--sl-button-font-size-large` | var(--sl-font-size-medium) |
|
||||
| Token | Value |
|
||||
| ------------------------------ | ----------------------------- |
|
||||
| `--sl-button-font-size-small` | `var(--sl-font-size-x-small)` |
|
||||
| `--sl-button-font-size-medium` | `var(--sl-font-size-small)` |
|
||||
| `--sl-button-font-size-large` | `var(--sl-font-size-medium)` |
|
||||
|
||||
## Form Inputs
|
||||
|
||||
Form input tokens control the appearance of form controls such as [input](/components/input), [select](/components/select), [textarea](/components/textarea), etc.
|
||||
|
||||
| Token | Value |
|
||||
| --------------------------------------- | -------------------------------- |
|
||||
| `--sl-input-height-small` | 1.875rem; (30px @ 16px base) |
|
||||
| `--sl-input-height-medium` | 2.5rem; (40px @ 16px base) |
|
||||
| `--sl-input-height-large` | 3.125rem; (50px @ 16px base) |
|
||||
| `--sl-input-background-color` | var(--sl-color-neutral-0) |
|
||||
| `--sl-input-background-color-hover` | var(--sl-input-background-color) |
|
||||
| `--sl-input-background-color-focus` | var(--sl-input-background-color) |
|
||||
| `--sl-input-background-color-disabled` | var(--sl-color-neutral-100) |
|
||||
| `--sl-input-border-color` | var(--sl-color-neutral-300) |
|
||||
| `--sl-input-border-color-hover` | var(--sl-color-neutral-400) |
|
||||
| `--sl-input-border-color-focus` | var(--sl-color-primary-500) |
|
||||
| `--sl-input-border-color-disabled` | var(--sl-color-neutral-300) |
|
||||
| `--sl-input-border-width` | 1px |
|
||||
| `--sl-input-required-content` | "\*" |
|
||||
| `--sl-input-required-content-offset` | -2px |
|
||||
| `--sl-input-required-content-color` | var(--sl-input-label-color) |
|
||||
| `--sl-input-border-radius-small` | var(--sl-border-radius-medium) |
|
||||
| `--sl-input-border-radius-medium` | var(--sl-border-radius-medium) |
|
||||
| `--sl-input-border-radius-large` | var(--sl-border-radius-medium) |
|
||||
| `--sl-input-font-family` | var(--sl-font-sans) |
|
||||
| `--sl-input-font-weight` | var(--sl-font-weight-normal) |
|
||||
| `--sl-input-font-size-small` | var(--sl-font-size-small) |
|
||||
| `--sl-input-font-size-medium` | var(--sl-font-size-medium) |
|
||||
| `--sl-input-font-size-large` | var(--sl-font-size-large) |
|
||||
| `--sl-input-letter-spacing` | var(--sl-letter-spacing-normal) |
|
||||
| `--sl-input-color` | var(--sl-color-neutral-700) |
|
||||
| `--sl-input-color-hover` | var(--sl-color-neutral-700) |
|
||||
| `--sl-input-color-focus` | var(--sl-color-neutral-700) |
|
||||
| `--sl-input-color-disabled` | var(--sl-color-neutral-900) |
|
||||
| `--sl-input-icon-color` | var(--sl-color-neutral-500) |
|
||||
| `--sl-input-icon-color-hover` | var(--sl-color-neutral-600) |
|
||||
| `--sl-input-icon-color-focus` | var(--sl-color-neutral-600) |
|
||||
| `--sl-input-placeholder-color` | var(--sl-color-neutral-500) |
|
||||
| `--sl-input-placeholder-color-disabled` | var(--sl-color-neutral-600) |
|
||||
| `--sl-input-spacing-small` | var(--sl-spacing-small) |
|
||||
| `--sl-input-spacing-medium` | var(--sl-spacing-medium) |
|
||||
| `--sl-input-spacing-large` | var(--sl-spacing-large) |
|
||||
| `--sl-input-focus-ring-color` | hsl(198.6 88.7% 48.4% / 40%) |
|
||||
| `--sl-input-focus-ring-offset` | 0 |
|
||||
| Token | Value |
|
||||
| --------------------------------------- | ---------------------------------- |
|
||||
| `--sl-input-height-small` | `1.875rem` (30px @ 16px base) |
|
||||
| `--sl-input-height-medium` | `2.5rem` (40px @ 16px base) |
|
||||
| `--sl-input-height-large` | `3.125rem` (50px @ 16px base) |
|
||||
| `--sl-input-background-color` | `var(--sl-color-neutral-0)` |
|
||||
| `--sl-input-background-color-hover` | `var(--sl-input-background-color)` |
|
||||
| `--sl-input-background-color-focus` | `var(--sl-input-background-color)` |
|
||||
| `--sl-input-background-color-disabled` | `var(--sl-color-neutral-100)` |
|
||||
| `--sl-input-border-color` | `var(--sl-color-neutral-300)` |
|
||||
| `--sl-input-border-color-hover` | `var(--sl-color-neutral-400)` |
|
||||
| `--sl-input-border-color-focus` | `var(--sl-color-primary-500)` |
|
||||
| `--sl-input-border-color-disabled` | `var(--sl-color-neutral-300)` |
|
||||
| `--sl-input-border-width` | `1px` |
|
||||
| `--sl-input-required-content` | `*` |
|
||||
| `--sl-input-required-content-offset` | `-2px` |
|
||||
| `--sl-input-required-content-color` | `var(--sl-input-label-color)` |
|
||||
| `--sl-input-border-radius-small` | `var(--sl-border-radius-medium)` |
|
||||
| `--sl-input-border-radius-medium` | `var(--sl-border-radius-medium)` |
|
||||
| `--sl-input-border-radius-large` | `var(--sl-border-radius-medium)` |
|
||||
| `--sl-input-font-family` | `var(--sl-font-sans)` |
|
||||
| `--sl-input-font-weight` | `var(--sl-font-weight-normal)` |
|
||||
| `--sl-input-font-size-small` | `var(--sl-font-size-small)` |
|
||||
| `--sl-input-font-size-medium` | `var(--sl-font-size-medium)` |
|
||||
| `--sl-input-font-size-large` | `var(--sl-font-size-large)` |
|
||||
| `--sl-input-letter-spacing` | `var(--sl-letter-spacing-normal)` |
|
||||
| `--sl-input-color` | `var(--sl-color-neutral-700)` |
|
||||
| `--sl-input-color-hover` | `var(--sl-color-neutral-700)` |
|
||||
| `--sl-input-color-focus` | `var(--sl-color-neutral-700)` |
|
||||
| `--sl-input-color-disabled` | `var(--sl-color-neutral-900)` |
|
||||
| `--sl-input-icon-color` | `var(--sl-color-neutral-500)` |
|
||||
| `--sl-input-icon-color-hover` | `var(--sl-color-neutral-600)` |
|
||||
| `--sl-input-icon-color-focus` | `var(--sl-color-neutral-600)` |
|
||||
| `--sl-input-placeholder-color` | `var(--sl-color-neutral-500)` |
|
||||
| `--sl-input-placeholder-color-disabled` | `var(--sl-color-neutral-600)` |
|
||||
| `--sl-input-spacing-small` | `var(--sl-spacing-small)` |
|
||||
| `--sl-input-spacing-medium` | `var(--sl-spacing-medium)` |
|
||||
| `--sl-input-spacing-large` | `var(--sl-spacing-large)` |
|
||||
| `--sl-input-focus-ring-color` | `hsl(198.6 88.7% 48.4% / 40%)` |
|
||||
| `--sl-input-focus-ring-offset` | `0` |
|
||||
|
||||
## Filled Form Inputs
|
||||
|
||||
Filled form input tokens control the appearance of form controls using the `filled` variant.
|
||||
|
||||
| Token | Value |
|
||||
| --------------------------------------------- | --------------------------- |
|
||||
| `--sl-input-filled-background-color` | var(--sl-color-neutral-100) |
|
||||
| `--sl-input-filled-background-color-hover` | var(--sl-color-neutral-100) |
|
||||
| `--sl-input-filled-background-color-focus` | var(--sl-color-neutral-100) |
|
||||
| `--sl-input-filled-background-color-disabled` | var(--sl-color-neutral-100) |
|
||||
| `--sl-input-filled-color` | var(--sl-color-neutral-800) |
|
||||
| `--sl-input-filled-color-hover` | var(--sl-color-neutral-800) |
|
||||
| `--sl-input-filled-color-focus` | var(--sl-color-neutral-700) |
|
||||
| `--sl-input-filled-color-disabled` | var(--sl-color-neutral-800) |
|
||||
| Token | Value |
|
||||
| --------------------------------------------- | ----------------------------- |
|
||||
| `--sl-input-filled-background-color` | `var(--sl-color-neutral-100)` |
|
||||
| `--sl-input-filled-background-color-hover` | `var(--sl-color-neutral-100)` |
|
||||
| `--sl-input-filled-background-color-focus` | `var(--sl-color-neutral-100)` |
|
||||
| `--sl-input-filled-background-color-disabled` | `var(--sl-color-neutral-100)` |
|
||||
| `--sl-input-filled-color` | `var(--sl-color-neutral-800)` |
|
||||
| `--sl-input-filled-color-hover` | `var(--sl-color-neutral-800)` |
|
||||
| `--sl-input-filled-color-focus` | `var(--sl-color-neutral-700)` |
|
||||
| `--sl-input-filled-color-disabled` | `var(--sl-color-neutral-800)` |
|
||||
|
||||
## Form Labels
|
||||
|
||||
Form label tokens control the appearance of labels in form controls.
|
||||
|
||||
| Token | Value |
|
||||
| ----------------------------------- | -------------------------- |
|
||||
| `--sl-input-label-font-size-small` | var(--sl-font-size-small) |
|
||||
| `--sl-input-label-font-size-medium` | var(--sl-font-size-medium) |
|
||||
| `--sl-input-label-font-size-large` | var(--sl-font-size-large) |
|
||||
| `--sl-input-label-color` | inherit |
|
||||
| Token | Value |
|
||||
| ----------------------------------- | ---------------------------- |
|
||||
| `--sl-input-label-font-size-small` | `var(--sl-font-size-small)` |
|
||||
| `--sl-input-label-font-size-medium` | `var(--sl-font-size-medium`) |
|
||||
| `--sl-input-label-font-size-large` | `var(--sl-font-size-large)` |
|
||||
| `--sl-input-label-color` | `inherit` |
|
||||
|
||||
## Help Text
|
||||
|
||||
Help text tokens control the appearance of help text in form controls.
|
||||
|
||||
| Token | Value |
|
||||
| --------------------------------------- | --------------------------- |
|
||||
| `--sl-input-help-text-font-size-small` | var(--sl-font-size-x-small) |
|
||||
| `--sl-input-help-text-font-size-medium` | var(--sl-font-size-small) |
|
||||
| `--sl-input-help-text-font-size-large` | var(--sl-font-size-medium) |
|
||||
| `--sl-input-help-text-color` | var(--sl-color-neutral-500) |
|
||||
| Token | Value |
|
||||
| --------------------------------------- | ----------------------------- |
|
||||
| `--sl-input-help-text-font-size-small` | `var(--sl-font-size-x-small)` |
|
||||
| `--sl-input-help-text-font-size-medium` | `var(--sl-font-size-small)` |
|
||||
| `--sl-input-help-text-font-size-large` | `var(--sl-font-size-medium)` |
|
||||
| `--sl-input-help-text-color` | `var(--sl-color-neutral-500)` |
|
||||
|
||||
## Toggles
|
||||
|
||||
Toggle tokens control the appearance of toggles such as [checkbox](/components/checkbox), [radio](/components/radio), [switch](/components/switch), etc.
|
||||
|
||||
| Token | Value |
|
||||
| ------------------------- | --------------------------- |
|
||||
| `--sl-toggle-size-small` | 0.875rem (14px @ 16px base) |
|
||||
| `--sl-toggle-size-medium` | 1.125rem (18px @ 16px base) |
|
||||
| `--sl-toggle-size-large` | 1.375rem (22px @ 16px base) |
|
||||
| Token | Value |
|
||||
| ------------------------- | ----------------------------- |
|
||||
| `--sl-toggle-size-small` | `0.875rem` (14px @ 16px base) |
|
||||
| `--sl-toggle-size-medium` | `1.125rem` (18px @ 16px base) |
|
||||
| `--sl-toggle-size-large` | `1.375rem` (22px @ 16px base) |
|
||||
|
||||
## Overlays
|
||||
|
||||
Overlay tokens control the appearance of overlays as used in [dialog](/components/dialog), [drawer](/components/drawer), etc.
|
||||
|
||||
| Token | Value |
|
||||
| ------------------------------- | ------------------------- |
|
||||
| `--sl-overlay-background-color` | hsl(240 3.8% 46.1% / 33%) |
|
||||
| Token | Value |
|
||||
| ------------------------------- | --------------------------- |
|
||||
| `--sl-overlay-background-color` | `hsl(240 3.8% 46.1% / 33%)` |
|
||||
|
||||
## Panels
|
||||
|
||||
Panel tokens control the appearance of panels such as those used in [dialog](/components/dialog), [drawer](/components/drawer), [menu](/components/menu), etc.
|
||||
|
||||
| Token | Value |
|
||||
| ----------------------------- | --------------------------- |
|
||||
| `--sl-panel-background-color` | var(--sl-color-neutral-0) |
|
||||
| `--sl-panel-border-color` | var(--sl-color-neutral-200) |
|
||||
| `--sl-panel-border-width` | 1px |
|
||||
| Token | Value |
|
||||
| ----------------------------- | ----------------------------- |
|
||||
| `--sl-panel-background-color` | `var(--sl-color-neutral-0)` |
|
||||
| `--sl-panel-border-color` | `var(--sl-color-neutral-200)` |
|
||||
| `--sl-panel-border-width` | `1px` |
|
||||
|
||||
## Tooltips
|
||||
|
||||
Tooltip tokens control the appearance of tooltips. This includes the [tooltip](/components/tooltip) component as well as other implementations, such [range tooltips](/components/range).
|
||||
|
||||
| Token | Value |
|
||||
| ------------------------------- | ---------------------------------------------------- |
|
||||
| `--sl-tooltip-border-radius` | var(--sl-border-radius-medium) |
|
||||
| `--sl-tooltip-background-color` | var(--sl-color-neutral-800) |
|
||||
| `--sl-tooltip-color` | var(--sl-color-neutral-0) |
|
||||
| `--sl-tooltip-font-family` | var(--sl-font-sans) |
|
||||
| `--sl-tooltip-font-weight` | var(--sl-font-weight-normal) |
|
||||
| `--sl-tooltip-font-size` | var(--sl-font-size-small) |
|
||||
| `--sl-tooltip-line-height` | var(--sl-line-height-dense) |
|
||||
| `--sl-tooltip-padding` | var(--sl-spacing-2x-small) var(--sl-spacing-x-small) |
|
||||
| `--sl-tooltip-arrow-size` | 6px |
|
||||
| Token | Value |
|
||||
| ------------------------------- | ------------------------------------------------------ |
|
||||
| `--sl-tooltip-border-radius` | `var(--sl-border-radius-medium)` |
|
||||
| `--sl-tooltip-background-color` | `var(--sl-color-neutral-800)` |
|
||||
| `--sl-tooltip-color` | `var(--sl-color-neutral-0)` |
|
||||
| `--sl-tooltip-font-family` | `var(--sl-font-sans)` |
|
||||
| `--sl-tooltip-font-weight` | `var(--sl-font-weight-normal)` |
|
||||
| `--sl-tooltip-font-size` | `var(--sl-font-size-small)` |
|
||||
| `--sl-tooltip-line-height` | `var(--sl-line-height-dense)` |
|
||||
| `--sl-tooltip-padding` | `var(--sl-spacing-2x-small) var(--sl-spacing-x-small)` |
|
||||
| `--sl-tooltip-arrow-size` | `6px` |
|
||||
|
||||
892
package-lock.json
generated
892
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@shoelace-style/shoelace",
|
||||
"description": "A forward-thinking library of web components.",
|
||||
"version": "2.5.2",
|
||||
"version": "2.6.0",
|
||||
"homepage": "https://github.com/shoelace-style/shoelace",
|
||||
"author": "Cory LaViska",
|
||||
"license": "MIT",
|
||||
@@ -9,12 +9,24 @@
|
||||
"web-types": "dist/web-types.json",
|
||||
"type": "module",
|
||||
"types": "dist/shoelace.d.ts",
|
||||
"jsdelivr": "./cdn/shoelace-autoloader.js",
|
||||
"sideEffects": [
|
||||
"./dist/shoelace.js",
|
||||
"./dist/shoelace-autoloader.js",
|
||||
"./dist/components/**/*.js",
|
||||
"./dist/translations/**/*.*",
|
||||
"./src/translations/**/*.*",
|
||||
"// COMMENT: This monstrosity below isn't perfect, but its like 99% to get bundlers to recognize 'thing.component.ts' as having no side effects. Example: https://regexr.com/7grof",
|
||||
"./dist/components/**/*((?<!(\\.component|\\.styles)))\\.js"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/shoelace.d.ts",
|
||||
"import": "./dist/shoelace.js"
|
||||
},
|
||||
"./dist/custom-elements.json": "./dist/custom-elements.json",
|
||||
"./dist/shoelace.js": "./dist/shoelace.js",
|
||||
"./dist/shoelace-autoloader.js": "./dist/shoelace-autoloader.js",
|
||||
"./dist/themes/*": "./dist/themes/*",
|
||||
"./dist/components/*": "./dist/components/*",
|
||||
"./dist/utilities/*": "./dist/utilities/*",
|
||||
@@ -79,8 +91,8 @@
|
||||
"@open-wc/testing": "^3.1.7",
|
||||
"@types/mocha": "^10.0.1",
|
||||
"@types/react": "^18.0.26",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||
"@typescript-eslint/parser": "^5.48.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@web/dev-server-esbuild": "^0.3.3",
|
||||
"@web/test-runner": "^0.15.0",
|
||||
"@web/test-runner-commands": "^0.6.5",
|
||||
@@ -96,15 +108,16 @@
|
||||
"del": "^7.0.0",
|
||||
"download": "^8.0.0",
|
||||
"esbuild": "^0.18.2",
|
||||
"eslint": "^8.31.0",
|
||||
"esbuild-plugin-replace": "^1.4.0",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-plugin-chai-expect": "^3.0.0",
|
||||
"eslint-plugin-chai-friendly": "^0.7.2",
|
||||
"eslint-plugin-import": "^2.27.4",
|
||||
"eslint-plugin-lit": "^1.8.2",
|
||||
"eslint-plugin-lit-a11y": "^2.3.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-lit": "^1.8.3",
|
||||
"eslint-plugin-lit-a11y": "^4.1.0",
|
||||
"eslint-plugin-markdown": "^3.0.0",
|
||||
"eslint-plugin-sort-imports-es6-autofix": "^0.6.0",
|
||||
"eslint-plugin-wc": "^1.4.0",
|
||||
"eslint-plugin-wc": "^1.5.0",
|
||||
"front-matter": "^4.0.2",
|
||||
"get-port": "^7.0.0",
|
||||
"globby": "^13.1.3",
|
||||
|
||||
@@ -11,6 +11,8 @@ import getPort, { portNumbers } from 'get-port';
|
||||
import ora from 'ora';
|
||||
import util from 'util';
|
||||
import * as path from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
import { replace } from 'esbuild-plugin-replace';
|
||||
|
||||
const { serve } = commandLineArgs([{ name: 'serve', type: Boolean }]);
|
||||
const outdir = 'dist';
|
||||
@@ -22,6 +24,8 @@ let childProcess;
|
||||
let buildResults;
|
||||
|
||||
const bundleDirectories = [cdndir, outdir];
|
||||
let packageData = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
|
||||
const shoelaceVersion = JSON.stringify(packageData.version.toString());
|
||||
|
||||
//
|
||||
// Runs 11ty and builds the docs. The returned promise resolves after the initial publish has completed. The child
|
||||
@@ -108,13 +112,18 @@ async function buildTheSource() {
|
||||
//
|
||||
external: alwaysExternal,
|
||||
splitting: true,
|
||||
plugins: []
|
||||
plugins: [
|
||||
replace({
|
||||
__SHOELACE_VERSION__: shoelaceVersion
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
const npmConfig = {
|
||||
...cdnConfig,
|
||||
bundle: false,
|
||||
external: undefined,
|
||||
minify: false,
|
||||
packages: 'external',
|
||||
outdir
|
||||
};
|
||||
|
||||
@@ -263,13 +272,16 @@ if (serve) {
|
||||
});
|
||||
|
||||
// Rebuild and reload when source files change
|
||||
bs.watch(['src/**/!(*.test).*']).on('change', async filename => {
|
||||
bs.watch('src/**/!(*.test).*').on('change', async filename => {
|
||||
console.log('[build] File changed: ', filename);
|
||||
|
||||
try {
|
||||
const isTheme = /^src\/themes/.test(filename);
|
||||
const isStylesheet = /(\.css|\.styles\.ts)$/.test(filename);
|
||||
|
||||
// Rebuild the source
|
||||
await Promise.all([buildResults.map(result => result.rebuild())]);
|
||||
const rebuildResults = buildResults.map(result => result.rebuild());
|
||||
await Promise.all(rebuildResults);
|
||||
|
||||
// Rebuild stylesheets when a theme file changes
|
||||
if (isTheme) {
|
||||
|
||||
@@ -25,24 +25,54 @@ components.map(component => {
|
||||
const componentDir = path.join(reactDir, tagWithoutPrefix);
|
||||
const componentFile = path.join(componentDir, 'index.ts');
|
||||
const importPath = component.path;
|
||||
const events = (component.events || []).map(event => `${event.reactName}: '${event.name}'`).join(',\n');
|
||||
const eventImports = (component.events || [])
|
||||
.map(event => `import { ${event.eventName} } from '../../../src/events/events';`)
|
||||
.join('\n');
|
||||
const eventNameImport =
|
||||
(component.events || []).length > 0 ? `import { type EventName } from '@lit-labs/react';` : ``;
|
||||
const events = (component.events || [])
|
||||
.map(event => `${event.reactName}: '${event.name}' as EventName<${event.eventName}>`)
|
||||
.join(',\n');
|
||||
|
||||
fs.mkdirSync(componentDir, { recursive: true });
|
||||
|
||||
const jsDoc = component.jsDoc || '';
|
||||
|
||||
const source = prettier.format(
|
||||
`
|
||||
import * as React from 'react';
|
||||
import { createComponent } from '@lit-labs/react';
|
||||
import Component from '../../${importPath}';
|
||||
|
||||
export default createComponent({
|
||||
tagName: '${component.tagName}',
|
||||
${eventNameImport}
|
||||
${eventImports}
|
||||
|
||||
const tagName = '${component.tagName}'
|
||||
|
||||
const component = createComponent({
|
||||
tagName,
|
||||
elementClass: Component,
|
||||
react: React,
|
||||
events: {
|
||||
${events}
|
||||
},
|
||||
displayName: "${component.name}"
|
||||
})
|
||||
|
||||
${jsDoc}
|
||||
class SlComponent extends React.Component<Parameters<typeof component>[0]> {
|
||||
constructor (...args: Parameters<typeof component>) {
|
||||
super(...args)
|
||||
Component.define(tagName)
|
||||
}
|
||||
});
|
||||
|
||||
render () {
|
||||
const { children, ...props } = this.props
|
||||
return React.createElement(component, props, children)
|
||||
}
|
||||
}
|
||||
|
||||
export default SlComponent;
|
||||
`,
|
||||
Object.assign(prettierConfig, {
|
||||
parser: 'babel-ts'
|
||||
|
||||
@@ -33,6 +33,11 @@ export default function (plop) {
|
||||
{
|
||||
type: 'add',
|
||||
path: '../../src/components/{{ tagWithoutPrefix tag }}/{{ tagWithoutPrefix tag }}.ts',
|
||||
templateFile: 'templates/component/define.hbs'
|
||||
},
|
||||
{
|
||||
type: 'add',
|
||||
path: '../../src/components/{{ tagWithoutPrefix tag }}/{{ tagWithoutPrefix tag }}.component.ts',
|
||||
templateFile: 'templates/component/component.hbs'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
@@ -23,7 +23,6 @@ import type { CSSResultGroup } from 'lit';
|
||||
*
|
||||
* @cssproperty --example - An example CSS custom property.
|
||||
*/
|
||||
@customElement('{{ tag }}')
|
||||
export default class {{ properCase tag }} extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
|
||||
4
scripts/plop/templates/component/define.hbs
Normal file
4
scripts/plop/templates/component/define.hbs
Normal file
@@ -0,0 +1,4 @@
|
||||
import {{ properCase tag }} from './{{ tagWithoutPrefix tag }}.component.js';
|
||||
export * from './{{ tagWithoutPrefix tag }}.component.js';
|
||||
export default {{ properCase tag }};
|
||||
{{ properCase tag }}.define('{{ tag }}');
|
||||
247
src/components/alert/alert.component.ts
Normal file
247
src/components/alert/alert.component.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIconButton from '../icon-button/icon-button.component.js';
|
||||
import styles from './alert.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
|
||||
|
||||
/**
|
||||
* @summary Alerts are used to display important messages inline or as toast notifications.
|
||||
* @documentation https://shoelace.style/components/alert
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The alert's main content.
|
||||
* @slot icon - An icon to show in the alert. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @event sl-show - Emitted when the alert opens.
|
||||
* @event sl-after-show - Emitted after the alert opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the alert closes.
|
||||
* @event sl-after-hide - Emitted after the alert closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart icon - The container that wraps the optional icon.
|
||||
* @csspart message - The container that wraps the alert's main content.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
*
|
||||
* @animation alert.show - The animation to use when showing the alert.
|
||||
* @animation alert.hide - The animation to use when hiding the alert.
|
||||
*/
|
||||
export default class SlAlert extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon-button': SlIconButton };
|
||||
|
||||
private autoHideTimeout: number;
|
||||
private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('[part~="base"]') base: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the alert's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/** Enables a close button that allows the user to dismiss the alert. */
|
||||
@property({ type: Boolean, reflect: true }) closable = false;
|
||||
|
||||
/** The alert's theme variant. */
|
||||
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
|
||||
|
||||
/**
|
||||
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with
|
||||
* the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning
|
||||
* the alert will not close on its own.
|
||||
*/
|
||||
@property({ type: Number }) duration = Infinity;
|
||||
|
||||
firstUpdated() {
|
||||
this.base.hidden = !this.open;
|
||||
}
|
||||
|
||||
private restartAutoHide() {
|
||||
clearTimeout(this.autoHideTimeout);
|
||||
if (this.open && this.duration < Infinity) {
|
||||
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCloseClick() {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private handleMouseMove() {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
|
||||
if (this.duration < Infinity) {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
await stopAnimations(this.base);
|
||||
this.base.hidden = false;
|
||||
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.base, keyframes, options);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
|
||||
clearTimeout(this.autoHideTimeout);
|
||||
|
||||
await stopAnimations(this.base);
|
||||
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.base, keyframes, options);
|
||||
this.base.hidden = true;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('duration')
|
||||
handleDurationChange() {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
/** Shows the alert. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the alert */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when
|
||||
* dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by
|
||||
* calling this method again. The returned promise will resolve after the alert is hidden.
|
||||
*/
|
||||
async toast() {
|
||||
return new Promise<void>(resolve => {
|
||||
if (toastStack.parentElement === null) {
|
||||
document.body.append(toastStack);
|
||||
}
|
||||
|
||||
toastStack.appendChild(this);
|
||||
|
||||
// Wait for the toast stack to render
|
||||
requestAnimationFrame(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition
|
||||
this.clientWidth;
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.addEventListener(
|
||||
'sl-after-hide',
|
||||
() => {
|
||||
toastStack.removeChild(this);
|
||||
resolve();
|
||||
|
||||
// Remove the toast stack from the DOM when there are no more alerts
|
||||
if (toastStack.querySelector('sl-alert') === null) {
|
||||
toastStack.remove();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
alert: true,
|
||||
'alert--open': this.open,
|
||||
'alert--closable': this.closable,
|
||||
'alert--has-icon': this.hasSlotController.test('icon'),
|
||||
'alert--primary': this.variant === 'primary',
|
||||
'alert--success': this.variant === 'success',
|
||||
'alert--neutral': this.variant === 'neutral',
|
||||
'alert--warning': this.variant === 'warning',
|
||||
'alert--danger': this.variant === 'danger'
|
||||
})}
|
||||
role="alert"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
@mousemove=${this.handleMouseMove}
|
||||
>
|
||||
<div part="icon" class="alert__icon">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
|
||||
<div part="message" class="alert__message" aria-live="polite">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
${this.closable
|
||||
? html`
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="alert__close-button"
|
||||
name="x-lg"
|
||||
library="system"
|
||||
label=${this.localize.term('close')}
|
||||
@click=${this.handleCloseClick}
|
||||
></sl-icon-button>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('alert.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('alert.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.8 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-alert': SlAlert;
|
||||
}
|
||||
}
|
||||
@@ -1,244 +1,4 @@
|
||||
import '../icon-button/icon-button.js';
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './alert.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
|
||||
|
||||
/**
|
||||
* @summary Alerts are used to display important messages inline or as toast notifications.
|
||||
* @documentation https://shoelace.style/components/alert
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The alert's main content.
|
||||
* @slot icon - An icon to show in the alert. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @event sl-show - Emitted when the alert opens.
|
||||
* @event sl-after-show - Emitted after the alert opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the alert closes.
|
||||
* @event sl-after-hide - Emitted after the alert closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart icon - The container that wraps the optional icon.
|
||||
* @csspart message - The container that wraps the alert's main content.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
*
|
||||
* @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 ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private autoHideTimeout: number;
|
||||
private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('[part~="base"]') base: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the alert is open. You can toggle this attribute to show and hide the alert, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the alert's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/** Enables a close button that allows the user to dismiss the alert. */
|
||||
@property({ type: Boolean, reflect: true }) closable = false;
|
||||
|
||||
/** The alert's theme variant. */
|
||||
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
|
||||
|
||||
/**
|
||||
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with
|
||||
* the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning
|
||||
* the alert will not close on its own.
|
||||
*/
|
||||
@property({ type: Number }) duration = Infinity;
|
||||
|
||||
firstUpdated() {
|
||||
this.base.hidden = !this.open;
|
||||
}
|
||||
|
||||
private restartAutoHide() {
|
||||
clearTimeout(this.autoHideTimeout);
|
||||
if (this.open && this.duration < Infinity) {
|
||||
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCloseClick() {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private handleMouseMove() {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
|
||||
if (this.duration < Infinity) {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
await stopAnimations(this.base);
|
||||
this.base.hidden = false;
|
||||
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.base, keyframes, options);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
|
||||
clearTimeout(this.autoHideTimeout);
|
||||
|
||||
await stopAnimations(this.base);
|
||||
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.base, keyframes, options);
|
||||
this.base.hidden = true;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('duration')
|
||||
handleDurationChange() {
|
||||
this.restartAutoHide();
|
||||
}
|
||||
|
||||
/** Shows the alert. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the alert */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when
|
||||
* dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by
|
||||
* calling this method again. The returned promise will resolve after the alert is hidden.
|
||||
*/
|
||||
async toast() {
|
||||
return new Promise<void>(resolve => {
|
||||
if (toastStack.parentElement === null) {
|
||||
document.body.append(toastStack);
|
||||
}
|
||||
|
||||
toastStack.appendChild(this);
|
||||
|
||||
// Wait for the toast stack to render
|
||||
requestAnimationFrame(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition
|
||||
this.clientWidth;
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.addEventListener(
|
||||
'sl-after-hide',
|
||||
() => {
|
||||
toastStack.removeChild(this);
|
||||
resolve();
|
||||
|
||||
// Remove the toast stack from the DOM when there are no more alerts
|
||||
if (toastStack.querySelector('sl-alert') === null) {
|
||||
toastStack.remove();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
alert: true,
|
||||
'alert--open': this.open,
|
||||
'alert--closable': this.closable,
|
||||
'alert--has-icon': this.hasSlotController.test('icon'),
|
||||
'alert--primary': this.variant === 'primary',
|
||||
'alert--success': this.variant === 'success',
|
||||
'alert--neutral': this.variant === 'neutral',
|
||||
'alert--warning': this.variant === 'warning',
|
||||
'alert--danger': this.variant === 'danger'
|
||||
})}
|
||||
role="alert"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
@mousemove=${this.handleMouseMove}
|
||||
>
|
||||
<slot name="icon" part="icon" class="alert__icon"></slot>
|
||||
|
||||
<slot part="message" class="alert__message" aria-live="polite"></slot>
|
||||
|
||||
${this.closable
|
||||
? html`
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="alert__close-button"
|
||||
name="x-lg"
|
||||
library="system"
|
||||
label=${this.localize.term('close')}
|
||||
@click=${this.handleCloseClick}
|
||||
></sl-icon-button>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('alert.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('alert.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.8 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-alert': SlAlert;
|
||||
}
|
||||
}
|
||||
import SlAlert from './alert.component.js';
|
||||
export * from './alert.component.js';
|
||||
export default SlAlert;
|
||||
SlAlert.define('sl-alert');
|
||||
|
||||
122
src/components/animated-image/animated-image.component.ts
Normal file
122
src/components/animated-image/animated-image.component.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './animated-image.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary A component for displaying animated GIFs and WEBPs that play and pause on interaction.
|
||||
* @documentation https://shoelace.style/components/animated-image
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-load - Emitted when the image loads successfully.
|
||||
* @event sl-error - Emitted when the image fails to load.
|
||||
*
|
||||
* @slot play-icon - Optional play icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot pause-icon - Optional pause icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @part - control-box - The container that surrounds the pause/play icons and provides their background.
|
||||
*
|
||||
* @cssproperty --control-box-size - The size of the icon box.
|
||||
* @cssproperty --icon-size - The size of the play/pause icons.
|
||||
*/
|
||||
export default class SlAnimatedImage extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
@query('.animated-image__animated') animatedImage: HTMLImageElement;
|
||||
|
||||
@state() frozenFrame: string;
|
||||
@state() isLoaded = false;
|
||||
|
||||
/** The path to the image to load. */
|
||||
@property() src: string;
|
||||
|
||||
/** A description of the image used by assistive devices. */
|
||||
@property() alt: string;
|
||||
|
||||
/** Plays the animation. When this attribute is remove, the animation will pause. */
|
||||
@property({ type: Boolean, reflect: true }) play: boolean;
|
||||
|
||||
private handleClick() {
|
||||
this.play = !this.play;
|
||||
}
|
||||
|
||||
private handleLoad() {
|
||||
const canvas = document.createElement('canvas');
|
||||
const { width, height } = this.animatedImage;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d')!.drawImage(this.animatedImage, 0, 0, width, height);
|
||||
this.frozenFrame = canvas.toDataURL('image/gif');
|
||||
|
||||
if (!this.isLoaded) {
|
||||
this.emit('sl-load');
|
||||
this.isLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError() {
|
||||
this.emit('sl-error');
|
||||
}
|
||||
|
||||
@watch('play', { waitUntilFirstUpdate: true })
|
||||
handlePlayChange() {
|
||||
// When the animation starts playing, reset the src so it plays from the beginning. Since the src is cached, this
|
||||
// won't trigger another request.
|
||||
if (this.play) {
|
||||
this.animatedImage.src = '';
|
||||
this.animatedImage.src = this.src;
|
||||
}
|
||||
}
|
||||
|
||||
@watch('src')
|
||||
handleSrcChange() {
|
||||
this.isLoaded = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="animated-image">
|
||||
<img
|
||||
class="animated-image__animated"
|
||||
src=${this.src}
|
||||
alt=${this.alt}
|
||||
crossorigin="anonymous"
|
||||
aria-hidden=${this.play ? 'false' : 'true'}
|
||||
@click=${this.handleClick}
|
||||
@load=${this.handleLoad}
|
||||
@error=${this.handleError}
|
||||
/>
|
||||
|
||||
${this.isLoaded
|
||||
? html`
|
||||
<img
|
||||
class="animated-image__frozen"
|
||||
src=${this.frozenFrame}
|
||||
alt=${this.alt}
|
||||
aria-hidden=${this.play ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
/>
|
||||
|
||||
<div part="control-box" class="animated-image__control-box">
|
||||
<slot name="play-icon"><sl-icon name="play-fill" library="system"></sl-icon></slot>
|
||||
<slot name="pause-icon"><sl-icon name="pause-fill" library="system"></sl-icon></slot>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-animated-image': SlAnimatedImage;
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './animated-image.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary A component for displaying animated GIFs and WEBPs that play and pause on interaction.
|
||||
* @documentation https://shoelace.style/components/animated-image
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-load - Emitted when the image loads successfully.
|
||||
* @event sl-error - Emitted when the image fails to load.
|
||||
*
|
||||
* @slot play-icon - Optional play icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot pause-icon - Optional pause icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @part - control-box - The container that surrounds the pause/play icons and provides their background.
|
||||
*
|
||||
* @cssproperty --control-box-size - The size of the icon box.
|
||||
* @cssproperty --icon-size - The size of the play/pause icons.
|
||||
*/
|
||||
@customElement('sl-animated-image')
|
||||
export default class SlAnimatedImage extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('.animated-image__animated') animatedImage: HTMLImageElement;
|
||||
|
||||
@state() frozenFrame: string;
|
||||
@state() isLoaded = false;
|
||||
|
||||
/** The path to the image to load. */
|
||||
@property() src: string;
|
||||
|
||||
/** A description of the image used by assistive devices. */
|
||||
@property() alt: string;
|
||||
|
||||
/** Plays the animation. When this attribute is remove, the animation will pause. */
|
||||
@property({ type: Boolean, reflect: true }) play: boolean;
|
||||
|
||||
private handleClick() {
|
||||
this.play = !this.play;
|
||||
}
|
||||
|
||||
private handleLoad() {
|
||||
const canvas = document.createElement('canvas');
|
||||
const { width, height } = this.animatedImage;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d')!.drawImage(this.animatedImage, 0, 0, width, height);
|
||||
this.frozenFrame = canvas.toDataURL('image/gif');
|
||||
|
||||
if (!this.isLoaded) {
|
||||
this.emit('sl-load');
|
||||
this.isLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError() {
|
||||
this.emit('sl-error');
|
||||
}
|
||||
|
||||
@watch('play', { waitUntilFirstUpdate: true })
|
||||
handlePlayChange() {
|
||||
// When the animation starts playing, reset the src so it plays from the beginning. Since the src is cached, this
|
||||
// won't trigger another request.
|
||||
if (this.play) {
|
||||
this.animatedImage.src = '';
|
||||
this.animatedImage.src = this.src;
|
||||
}
|
||||
}
|
||||
|
||||
@watch('src')
|
||||
handleSrcChange() {
|
||||
this.isLoaded = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="animated-image">
|
||||
<img
|
||||
class="animated-image__animated"
|
||||
src=${this.src}
|
||||
alt=${this.alt}
|
||||
crossorigin="anonymous"
|
||||
aria-hidden=${this.play ? 'false' : 'true'}
|
||||
@click=${this.handleClick}
|
||||
@load=${this.handleLoad}
|
||||
@error=${this.handleError}
|
||||
/>
|
||||
|
||||
${this.isLoaded
|
||||
? html`
|
||||
<img
|
||||
class="animated-image__frozen"
|
||||
src=${this.frozenFrame}
|
||||
alt=${this.alt}
|
||||
aria-hidden=${this.play ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
/>
|
||||
|
||||
<div part="control-box" class="animated-image__control-box">
|
||||
<slot name="play-icon"><sl-icon name="play-fill" library="system"></sl-icon></slot>
|
||||
<slot name="pause-icon"><sl-icon name="pause-fill" library="system"></sl-icon></slot>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-animated-image': SlAnimatedImage;
|
||||
}
|
||||
}
|
||||
import SlAnimatedImage from './animated-image.component.js';
|
||||
export * from './animated-image.component.js';
|
||||
export default SlAnimatedImage;
|
||||
SlAnimatedImage.define('sl-animated-image');
|
||||
|
||||
226
src/components/animation/animation.component.ts
Normal file
226
src/components/animation/animation.component.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { animations } from './animations.js';
|
||||
import { html } from 'lit';
|
||||
import { property, queryAsync } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './animation.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Animate elements declaratively with nearly 100 baked-in presets, or roll your own with custom keyframes. Powered by the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API).
|
||||
* @documentation https://shoelace.style/components/animation
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-cancel - Emitted when the animation is canceled.
|
||||
* @event sl-finish - Emitted when the animation finishes.
|
||||
* @event sl-start - Emitted when the animation starts or restarts.
|
||||
*
|
||||
* @slot - The element to animate. Avoid slotting in more than one element, as subsequent ones will be ignored. To
|
||||
* animate multiple elements, either wrap them in a single container or use multiple `<sl-animation>` elements.
|
||||
*/
|
||||
export default class SlAnimation extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private animation?: Animation;
|
||||
private hasStarted = false;
|
||||
|
||||
@queryAsync('slot') defaultSlot: Promise<HTMLSlotElement>;
|
||||
|
||||
/** The name of the built-in animation to use. For custom animations, use the `keyframes` prop. */
|
||||
@property() name = 'none';
|
||||
|
||||
/**
|
||||
* Plays the animation. When omitted, the animation will be paused. This attribute will be automatically removed when
|
||||
* the animation finishes or gets canceled.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) play = false;
|
||||
|
||||
/** The number of milliseconds to delay the start of the animation. */
|
||||
@property({ type: Number }) delay = 0;
|
||||
|
||||
/**
|
||||
* Determines the direction of playback as well as the behavior when reaching the end of an iteration.
|
||||
* [Learn more](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-direction)
|
||||
*/
|
||||
@property() direction: PlaybackDirection = 'normal';
|
||||
|
||||
/** The number of milliseconds each iteration of the animation takes to complete. */
|
||||
@property({ type: Number }) duration = 1000;
|
||||
|
||||
/**
|
||||
* The easing function to use for the animation. This can be a Shoelace easing function or a custom easing function
|
||||
* such as `cubic-bezier(0, 1, .76, 1.14)`.
|
||||
*/
|
||||
@property() easing = 'linear';
|
||||
|
||||
/** The number of milliseconds to delay after the active period of an animation sequence. */
|
||||
@property({ attribute: 'end-delay', type: Number }) endDelay = 0;
|
||||
|
||||
/** Sets how the animation applies styles to its target before and after its execution. */
|
||||
@property() fill: FillMode = 'auto';
|
||||
|
||||
/** The number of iterations to run before the animation completes. Defaults to `Infinity`, which loops. */
|
||||
@property({ type: Number }) iterations = Infinity;
|
||||
|
||||
/** The offset at which to start the animation, usually between 0 (start) and 1 (end). */
|
||||
@property({ attribute: 'iteration-start', type: Number }) iterationStart = 0;
|
||||
|
||||
/** The keyframes to use for the animation. If this is set, `name` will be ignored. */
|
||||
@property({ attribute: false }) keyframes?: Keyframe[];
|
||||
|
||||
/**
|
||||
* Sets the animation's playback rate. The default is `1`, which plays the animation at a normal speed. Setting this
|
||||
* to `2`, for example, will double the animation's speed. A negative value can be used to reverse the animation. This
|
||||
* value can be changed without causing the animation to restart.
|
||||
*/
|
||||
@property({ attribute: 'playback-rate', type: Number }) playbackRate = 1;
|
||||
|
||||
/** Gets and sets the current animation time. */
|
||||
get currentTime(): CSSNumberish {
|
||||
return this.animation?.currentTime ?? 0;
|
||||
}
|
||||
|
||||
set currentTime(time: number) {
|
||||
if (this.animation) {
|
||||
this.animation.currentTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.destroyAnimation();
|
||||
}
|
||||
|
||||
private handleAnimationFinish = () => {
|
||||
this.play = false;
|
||||
this.hasStarted = false;
|
||||
this.emit('sl-finish');
|
||||
};
|
||||
|
||||
private handleAnimationCancel = () => {
|
||||
this.play = false;
|
||||
this.hasStarted = false;
|
||||
this.emit('sl-cancel');
|
||||
};
|
||||
|
||||
private handleSlotChange() {
|
||||
this.destroyAnimation();
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
private async createAnimation() {
|
||||
const easing = animations.easings[this.easing] ?? this.easing;
|
||||
const keyframes = this.keyframes ?? (animations as unknown as Partial<Record<string, Keyframe[]>>)[this.name];
|
||||
const slot = await this.defaultSlot;
|
||||
const element = slot.assignedElements()[0] as HTMLElement | undefined;
|
||||
|
||||
if (!element || !keyframes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.destroyAnimation();
|
||||
this.animation = element.animate(keyframes, {
|
||||
delay: this.delay,
|
||||
direction: this.direction,
|
||||
duration: this.duration,
|
||||
easing,
|
||||
endDelay: this.endDelay,
|
||||
fill: this.fill,
|
||||
iterationStart: this.iterationStart,
|
||||
iterations: this.iterations
|
||||
});
|
||||
this.animation.playbackRate = this.playbackRate;
|
||||
this.animation.addEventListener('cancel', this.handleAnimationCancel);
|
||||
this.animation.addEventListener('finish', this.handleAnimationFinish);
|
||||
|
||||
if (this.play) {
|
||||
this.hasStarted = true;
|
||||
this.emit('sl-start');
|
||||
} else {
|
||||
this.animation.pause();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private destroyAnimation() {
|
||||
if (this.animation) {
|
||||
this.animation.cancel();
|
||||
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
|
||||
this.animation.removeEventListener('finish', this.handleAnimationFinish);
|
||||
this.hasStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
@watch([
|
||||
'name',
|
||||
'delay',
|
||||
'direction',
|
||||
'duration',
|
||||
'easing',
|
||||
'endDelay',
|
||||
'fill',
|
||||
'iterations',
|
||||
'iterationsStart',
|
||||
'keyframes'
|
||||
])
|
||||
handleAnimationChange() {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
@watch('play')
|
||||
handlePlayChange() {
|
||||
if (this.animation) {
|
||||
if (this.play && !this.hasStarted) {
|
||||
this.hasStarted = true;
|
||||
this.emit('sl-start');
|
||||
}
|
||||
|
||||
if (this.play) {
|
||||
this.animation.play();
|
||||
} else {
|
||||
this.animation.pause();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@watch('playbackRate')
|
||||
handlePlaybackRateChange() {
|
||||
if (this.animation) {
|
||||
this.animation.playbackRate = this.playbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears all keyframe effects caused by this animation and aborts its playback. */
|
||||
cancel() {
|
||||
this.animation?.cancel();
|
||||
}
|
||||
|
||||
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
|
||||
finish() {
|
||||
this.animation?.finish();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-animation': SlAnimation;
|
||||
}
|
||||
}
|
||||
@@ -1,227 +1,4 @@
|
||||
import { animations } from './animations.js';
|
||||
import { customElement, property, queryAsync } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './animation.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Animate elements declaratively with nearly 100 baked-in presets, or roll your own with custom keyframes. Powered by the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API).
|
||||
* @documentation https://shoelace.style/components/animation
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-cancel - Emitted when the animation is canceled.
|
||||
* @event sl-finish - Emitted when the animation finishes.
|
||||
* @event sl-start - Emitted when the animation starts or restarts.
|
||||
*
|
||||
* @slot - The element to animate. Avoid slotting in more than one element, as subsequent ones will be ignored. To
|
||||
* animate multiple elements, either wrap them in a single container or use multiple `<sl-animation>` elements.
|
||||
*/
|
||||
@customElement('sl-animation')
|
||||
export default class SlAnimation extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private animation?: Animation;
|
||||
private hasStarted = false;
|
||||
|
||||
@queryAsync('slot') defaultSlot: Promise<HTMLSlotElement>;
|
||||
|
||||
/** The name of the built-in animation to use. For custom animations, use the `keyframes` prop. */
|
||||
@property() name = 'none';
|
||||
|
||||
/**
|
||||
* Plays the animation. When omitted, the animation will be paused. This attribute will be automatically removed when
|
||||
* the animation finishes or gets canceled.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) play = false;
|
||||
|
||||
/** The number of milliseconds to delay the start of the animation. */
|
||||
@property({ type: Number }) delay = 0;
|
||||
|
||||
/**
|
||||
* Determines the direction of playback as well as the behavior when reaching the end of an iteration.
|
||||
* [Learn more](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-direction)
|
||||
*/
|
||||
@property() direction: PlaybackDirection = 'normal';
|
||||
|
||||
/** The number of milliseconds each iteration of the animation takes to complete. */
|
||||
@property({ type: Number }) duration = 1000;
|
||||
|
||||
/**
|
||||
* The easing function to use for the animation. This can be a Shoelace easing function or a custom easing function
|
||||
* such as `cubic-bezier(0, 1, .76, 1.14)`.
|
||||
*/
|
||||
@property() easing = 'linear';
|
||||
|
||||
/** The number of milliseconds to delay after the active period of an animation sequence. */
|
||||
@property({ attribute: 'end-delay', type: Number }) endDelay = 0;
|
||||
|
||||
/** Sets how the animation applies styles to its target before and after its execution. */
|
||||
@property() fill: FillMode = 'auto';
|
||||
|
||||
/** The number of iterations to run before the animation completes. Defaults to `Infinity`, which loops. */
|
||||
@property({ type: Number }) iterations = Infinity;
|
||||
|
||||
/** The offset at which to start the animation, usually between 0 (start) and 1 (end). */
|
||||
@property({ attribute: 'iteration-start', type: Number }) iterationStart = 0;
|
||||
|
||||
/** The keyframes to use for the animation. If this is set, `name` will be ignored. */
|
||||
@property({ attribute: false }) keyframes?: Keyframe[];
|
||||
|
||||
/**
|
||||
* Sets the animation's playback rate. The default is `1`, which plays the animation at a normal speed. Setting this
|
||||
* to `2`, for example, will double the animation's speed. A negative value can be used to reverse the animation. This
|
||||
* value can be changed without causing the animation to restart.
|
||||
*/
|
||||
@property({ attribute: 'playback-rate', type: Number }) playbackRate = 1;
|
||||
|
||||
/** Gets and sets the current animation time. */
|
||||
get currentTime(): CSSNumberish {
|
||||
return this.animation?.currentTime ?? 0;
|
||||
}
|
||||
|
||||
set currentTime(time: number) {
|
||||
if (this.animation) {
|
||||
this.animation.currentTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.destroyAnimation();
|
||||
}
|
||||
|
||||
private handleAnimationFinish = () => {
|
||||
this.play = false;
|
||||
this.hasStarted = false;
|
||||
this.emit('sl-finish');
|
||||
};
|
||||
|
||||
private handleAnimationCancel = () => {
|
||||
this.play = false;
|
||||
this.hasStarted = false;
|
||||
this.emit('sl-cancel');
|
||||
};
|
||||
|
||||
private handleSlotChange() {
|
||||
this.destroyAnimation();
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
private async createAnimation() {
|
||||
const easing = animations.easings[this.easing] ?? this.easing;
|
||||
const keyframes = this.keyframes ?? (animations as unknown as Partial<Record<string, Keyframe[]>>)[this.name];
|
||||
const slot = await this.defaultSlot;
|
||||
const element = slot.assignedElements()[0] as HTMLElement | undefined;
|
||||
|
||||
if (!element || !keyframes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.destroyAnimation();
|
||||
this.animation = element.animate(keyframes, {
|
||||
delay: this.delay,
|
||||
direction: this.direction,
|
||||
duration: this.duration,
|
||||
easing,
|
||||
endDelay: this.endDelay,
|
||||
fill: this.fill,
|
||||
iterationStart: this.iterationStart,
|
||||
iterations: this.iterations
|
||||
});
|
||||
this.animation.playbackRate = this.playbackRate;
|
||||
this.animation.addEventListener('cancel', this.handleAnimationCancel);
|
||||
this.animation.addEventListener('finish', this.handleAnimationFinish);
|
||||
|
||||
if (this.play) {
|
||||
this.hasStarted = true;
|
||||
this.emit('sl-start');
|
||||
} else {
|
||||
this.animation.pause();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private destroyAnimation() {
|
||||
if (this.animation) {
|
||||
this.animation.cancel();
|
||||
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
|
||||
this.animation.removeEventListener('finish', this.handleAnimationFinish);
|
||||
this.hasStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
@watch([
|
||||
'name',
|
||||
'delay',
|
||||
'direction',
|
||||
'duration',
|
||||
'easing',
|
||||
'endDelay',
|
||||
'fill',
|
||||
'iterations',
|
||||
'iterationsStart',
|
||||
'keyframes'
|
||||
])
|
||||
handleAnimationChange() {
|
||||
if (!this.hasUpdated) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createAnimation();
|
||||
}
|
||||
|
||||
@watch('play')
|
||||
handlePlayChange() {
|
||||
if (this.animation) {
|
||||
if (this.play && !this.hasStarted) {
|
||||
this.hasStarted = true;
|
||||
this.emit('sl-start');
|
||||
}
|
||||
|
||||
if (this.play) {
|
||||
this.animation.play();
|
||||
} else {
|
||||
this.animation.pause();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@watch('playbackRate')
|
||||
handlePlaybackRateChange() {
|
||||
if (this.animation) {
|
||||
this.animation.playbackRate = this.playbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears all keyframe effects caused by this animation and aborts its playback. */
|
||||
cancel() {
|
||||
this.animation?.cancel();
|
||||
}
|
||||
|
||||
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
|
||||
finish() {
|
||||
this.animation?.finish();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot @slotchange=${this.handleSlotChange}></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-animation': SlAnimation;
|
||||
}
|
||||
}
|
||||
import SlAnimation from './animation.component.js';
|
||||
export * from './animation.component.js';
|
||||
export default SlAnimation;
|
||||
SlAnimation.define('sl-animation');
|
||||
|
||||
104
src/components/avatar/avatar.component.ts
Normal file
104
src/components/avatar/avatar.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './avatar.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Avatars are used to represent a person or object.
|
||||
* @documentation https://shoelace.style/components/avatar
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot icon - The default icon to use when no image or initials are present. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart icon - The container that wraps the avatar's icon.
|
||||
* @csspart initials - The container that wraps the avatar's initials.
|
||||
* @csspart image - The avatar image. Only shown when the `image` attribute is set.
|
||||
*
|
||||
* @cssproperty --size - The size of the avatar.
|
||||
*/
|
||||
export default class SlAvatar extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon
|
||||
};
|
||||
|
||||
@state() private hasError = false;
|
||||
|
||||
/** The image source to use for the avatar. */
|
||||
@property() image = '';
|
||||
|
||||
/** A label to use to describe the avatar to assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
/** Initials to use as a fallback when no image is available (1-2 characters max recommended). */
|
||||
@property() initials = '';
|
||||
|
||||
/** Indicates how the browser should load the image. */
|
||||
@property() loading: 'eager' | 'lazy' = 'eager';
|
||||
|
||||
/** The shape of the avatar. */
|
||||
@property({ reflect: true }) shape: 'circle' | 'square' | 'rounded' = 'circle';
|
||||
|
||||
@watch('image')
|
||||
handleImageChange() {
|
||||
// Reset the error when a new image is provided
|
||||
this.hasError = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const avatarWithImage = html`
|
||||
<img
|
||||
part="image"
|
||||
class="avatar__image"
|
||||
src="${this.image}"
|
||||
loading="${this.loading}"
|
||||
alt=""
|
||||
@error="${() => (this.hasError = true)}"
|
||||
/>
|
||||
`;
|
||||
|
||||
let avatarWithoutImage = html``;
|
||||
|
||||
if (this.initials) {
|
||||
avatarWithoutImage = html`<div part="initials" class="avatar__initials">${this.initials}</div>`;
|
||||
} else {
|
||||
avatarWithoutImage = html`
|
||||
<div part="icon" class="avatar__icon" aria-hidden="true">
|
||||
<slot name="icon">
|
||||
<sl-icon name="person-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
avatar: true,
|
||||
'avatar--circle': this.shape === 'circle',
|
||||
'avatar--rounded': this.shape === 'rounded',
|
||||
'avatar--square': this.shape === 'square'
|
||||
})}
|
||||
role="img"
|
||||
aria-label=${this.label}
|
||||
>
|
||||
${this.image && !this.hasError ? avatarWithImage : avatarWithoutImage}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-avatar': SlAvatar;
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './avatar.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Avatars are used to represent a person or object.
|
||||
* @documentation https://shoelace.style/components/avatar
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot icon - The default icon to use when no image or initials are present. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart icon - The container that wraps the avatar's icon.
|
||||
* @csspart initials - The container that wraps the avatar's initials.
|
||||
* @csspart image - The avatar image. Only shown when the `image` attribute is set.
|
||||
*
|
||||
* @cssproperty --size - The size of the avatar.
|
||||
*/
|
||||
@customElement('sl-avatar')
|
||||
export default class SlAvatar extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@state() private hasError = false;
|
||||
|
||||
/** The image source to use for the avatar. */
|
||||
@property() image = '';
|
||||
|
||||
/** A label to use to describe the avatar to assistive devices. */
|
||||
@property() label = '';
|
||||
|
||||
/** Initials to use as a fallback when no image is available (1-2 characters max recommended). */
|
||||
@property() initials = '';
|
||||
|
||||
/** Indicates how the browser should load the image. */
|
||||
@property() loading: 'eager' | 'lazy' = 'eager';
|
||||
|
||||
/** The shape of the avatar. */
|
||||
@property({ reflect: true }) shape: 'circle' | 'square' | 'rounded' = 'circle';
|
||||
|
||||
@watch('image')
|
||||
handleImageChange() {
|
||||
// Reset the error when a new image is provided
|
||||
this.hasError = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const avatarWithImage = html`
|
||||
<img
|
||||
part="image"
|
||||
class="avatar__image"
|
||||
src="${this.image}"
|
||||
loading="${this.loading}"
|
||||
alt=""
|
||||
@error="${() => (this.hasError = true)}"
|
||||
/>
|
||||
`;
|
||||
|
||||
let avatarWithoutImage = html``;
|
||||
|
||||
if (this.initials) {
|
||||
avatarWithoutImage = html`<div part="initials" class="avatar__initials">${this.initials}</div>`;
|
||||
} else {
|
||||
avatarWithoutImage = html`
|
||||
<slot name="icon" part="icon" class="avatar__icon" aria-hidden="true">
|
||||
<sl-icon name="person-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
avatar: true,
|
||||
'avatar--circle': this.shape === 'circle',
|
||||
'avatar--rounded': this.shape === 'rounded',
|
||||
'avatar--square': this.shape === 'square'
|
||||
})}
|
||||
role="img"
|
||||
aria-label=${this.label}
|
||||
>
|
||||
${this.image && !this.hasError ? avatarWithImage : avatarWithoutImage}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-avatar': SlAvatar;
|
||||
}
|
||||
}
|
||||
import SlAvatar from './avatar.component.js';
|
||||
export * from './avatar.component.js';
|
||||
export default SlAvatar;
|
||||
SlAvatar.define('sl-avatar');
|
||||
|
||||
56
src/components/badge/badge.component.ts
Normal file
56
src/components/badge/badge.component.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './badge.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Badges are used to draw attention and display statuses or counts.
|
||||
* @documentation https://shoelace.style/components/badge
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The badge's content.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlBadge extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** The badge's theme variant. */
|
||||
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
|
||||
|
||||
/** Draws a pill-style badge with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** Makes the badge pulsate to draw attention. */
|
||||
@property({ type: Boolean, reflect: true }) pulse = false;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<span
|
||||
part="base"
|
||||
class=${classMap({
|
||||
badge: true,
|
||||
'badge--primary': this.variant === 'primary',
|
||||
'badge--success': this.variant === 'success',
|
||||
'badge--neutral': this.variant === 'neutral',
|
||||
'badge--warning': this.variant === 'warning',
|
||||
'badge--danger': this.variant === 'danger',
|
||||
'badge--pill': this.pill,
|
||||
'badge--pulse': this.pulse
|
||||
})}
|
||||
role="status"
|
||||
>
|
||||
<slot></slot>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-badge': SlBadge;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import '../../../dist/shoelace.js';
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import type SlBadge from './badge.js';
|
||||
|
||||
// The default badge background just misses AA contrast, but the next step up is way too dark. We're going to relax this
|
||||
// rule for now.
|
||||
const ignoredRules = ['color-contrast'];
|
||||
|
||||
describe('<sl-badge>', () => {
|
||||
let el: SlBadge;
|
||||
|
||||
@@ -11,7 +15,7 @@ describe('<sl-badge>', () => {
|
||||
});
|
||||
|
||||
it('should pass accessibility tests with a role of status on the base part.', async () => {
|
||||
await expect(el).to.be.accessible();
|
||||
await expect(el).to.be.accessible({ ignoredRules });
|
||||
|
||||
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
|
||||
expect(part.getAttribute('role')).to.eq('status');
|
||||
@@ -33,7 +37,7 @@ describe('<sl-badge>', () => {
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', async () => {
|
||||
await expect(el).to.be.accessible();
|
||||
await expect(el).to.be.accessible({ ignoredRules });
|
||||
});
|
||||
|
||||
it('should append the pill class to the classlist to render a pill', () => {
|
||||
@@ -48,7 +52,7 @@ describe('<sl-badge>', () => {
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', async () => {
|
||||
await expect(el).to.be.accessible();
|
||||
await expect(el).to.be.accessible({ ignoredRules });
|
||||
});
|
||||
|
||||
it('should append the pulse class to the classlist to render a pulse', () => {
|
||||
@@ -64,7 +68,7 @@ describe('<sl-badge>', () => {
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', async () => {
|
||||
await expect(el).to.be.accessible();
|
||||
await expect(el).to.be.accessible({ ignoredRules });
|
||||
});
|
||||
|
||||
it('should default to square styling, with the primary color', () => {
|
||||
|
||||
@@ -1,55 +1,4 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './badge.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Badges are used to draw attention and display statuses or counts.
|
||||
* @documentation https://shoelace.style/components/badge
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The badge's content.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-badge')
|
||||
export default class SlBadge extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** The badge's theme variant. */
|
||||
@property({ reflect: true }) variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary';
|
||||
|
||||
/** Draws a pill-style badge with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** Makes the badge pulsate to draw attention. */
|
||||
@property({ type: Boolean, reflect: true }) pulse = false;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot
|
||||
part="base"
|
||||
class=${classMap({
|
||||
badge: true,
|
||||
'badge--primary': this.variant === 'primary',
|
||||
'badge--success': this.variant === 'success',
|
||||
'badge--neutral': this.variant === 'neutral',
|
||||
'badge--warning': this.variant === 'warning',
|
||||
'badge--danger': this.variant === 'danger',
|
||||
'badge--pill': this.pill,
|
||||
'badge--pulse': this.pulse
|
||||
})}
|
||||
role="status"
|
||||
></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-badge': SlBadge;
|
||||
}
|
||||
}
|
||||
import SlBadge from './badge.component.js';
|
||||
export * from './badge.component.js';
|
||||
export default SlBadge;
|
||||
SlBadge.define('sl-badge');
|
||||
|
||||
95
src/components/breadcrumb-item/breadcrumb-item.component.ts
Normal file
95
src/components/breadcrumb-item/breadcrumb-item.component.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './breadcrumb-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Breadcrumb Items are used inside [breadcrumbs](/components/breadcrumb) to represent different links.
|
||||
* @documentation https://shoelace.style/components/breadcrumb-item
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The breadcrumb item's label.
|
||||
* @slot prefix - An optional prefix, usually an icon or icon button.
|
||||
* @slot suffix - An optional suffix, usually an icon or icon button.
|
||||
* @slot separator - The separator to use for the breadcrumb item. This will only change the separator for this item. If
|
||||
* you want to change it for all items in the group, set the separator on `<sl-breadcrumb>` instead.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The breadcrumb item's label.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
* @csspart separator - The container that wraps the separator.
|
||||
*/
|
||||
export default class SlBreadcrumbItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'prefix', 'suffix');
|
||||
|
||||
/**
|
||||
* Optional URL to direct the user to when the breadcrumb item is activated. When set, a link will be rendered
|
||||
* internally. When unset, a button will be rendered instead.
|
||||
*/
|
||||
@property() href?: string;
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is set. */
|
||||
@property() target?: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/** The `rel` attribute to use on the link. Only used when `href` is set. */
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
render() {
|
||||
const isLink = this.href ? true : false;
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'breadcrumb-item': true,
|
||||
'breadcrumb-item--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'breadcrumb-item--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
>
|
||||
<span part="prefix" class="breadcrumb-item__prefix">
|
||||
<slot name="prefix"></slot>
|
||||
</span>
|
||||
|
||||
${isLink
|
||||
? html`
|
||||
<a
|
||||
part="label"
|
||||
class="breadcrumb-item__label breadcrumb-item__label--link"
|
||||
href="${this.href!}"
|
||||
target="${ifDefined(this.target ? this.target : undefined)}"
|
||||
rel=${ifDefined(this.target ? this.rel : undefined)}
|
||||
>
|
||||
<slot></slot>
|
||||
</a>
|
||||
`
|
||||
: html`
|
||||
<button part="label" type="button" class="breadcrumb-item__label breadcrumb-item__label--button">
|
||||
<slot></slot>
|
||||
</button>
|
||||
`}
|
||||
|
||||
<span part="suffix" class="breadcrumb-item__suffix">
|
||||
<slot name="suffix"></slot>
|
||||
</span>
|
||||
|
||||
<span part="separator" class="breadcrumb-item__separator" aria-hidden="true">
|
||||
<slot name="separator"></slot>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-breadcrumb-item': SlBreadcrumbItem;
|
||||
}
|
||||
}
|
||||
@@ -1,90 +1,4 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './breadcrumb-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Breadcrumb Items are used inside [breadcrumbs](/components/breadcrumb) to represent different links.
|
||||
* @documentation https://shoelace.style/components/breadcrumb-item
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The breadcrumb item's label.
|
||||
* @slot prefix - An optional prefix, usually an icon or icon button.
|
||||
* @slot suffix - An optional suffix, usually an icon or icon button.
|
||||
* @slot separator - The separator to use for the breadcrumb item. This will only change the separator for this item. If
|
||||
* you want to change it for all items in the group, set the separator on `<sl-breadcrumb>` instead.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The breadcrumb item's label.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
* @csspart separator - The container that wraps the separator.
|
||||
*/
|
||||
@customElement('sl-breadcrumb-item')
|
||||
export default class SlBreadcrumbItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'prefix', 'suffix');
|
||||
|
||||
/**
|
||||
* Optional URL to direct the user to when the breadcrumb item is activated. When set, a link will be rendered
|
||||
* internally. When unset, a button will be rendered instead.
|
||||
*/
|
||||
@property() href?: string;
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is set. */
|
||||
@property() target?: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/** The `rel` attribute to use on the link. Only used when `href` is set. */
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
render() {
|
||||
const isLink = this.href ? true : false;
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'breadcrumb-item': true,
|
||||
'breadcrumb-item--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'breadcrumb-item--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="breadcrumb-item__prefix"></slot>
|
||||
|
||||
${isLink
|
||||
? html`
|
||||
<a
|
||||
part="label"
|
||||
class="breadcrumb-item__label breadcrumb-item__label--link"
|
||||
href="${this.href!}"
|
||||
target="${ifDefined(this.target ? this.target : undefined)}"
|
||||
rel=${ifDefined(this.target ? this.rel : undefined)}
|
||||
>
|
||||
<slot></slot>
|
||||
</a>
|
||||
`
|
||||
: html`
|
||||
<button part="label" type="button" class="breadcrumb-item__label breadcrumb-item__label--button">
|
||||
<slot></slot>
|
||||
</button>
|
||||
`}
|
||||
|
||||
<slot name="suffix" part="suffix" class="breadcrumb-item__suffix"></slot>
|
||||
|
||||
<slot name="separator" part="separator" class="breadcrumb-item__separator" aria-hidden="true"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-breadcrumb-item': SlBreadcrumbItem;
|
||||
}
|
||||
}
|
||||
import SlBreadcrumbItem from './breadcrumb-item.component.js';
|
||||
export * from './breadcrumb-item.component.js';
|
||||
export default SlBreadcrumbItem;
|
||||
SlBreadcrumbItem.define('sl-breadcrumb-item');
|
||||
|
||||
106
src/components/breadcrumb/breadcrumb.component.ts
Normal file
106
src/components/breadcrumb/breadcrumb.component.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './breadcrumb.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item.js';
|
||||
|
||||
/**
|
||||
* @summary Breadcrumbs provide a group of links so users can easily navigate a website's hierarchy.
|
||||
* @documentation https://shoelace.style/components/breadcrumb
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more breadcrumb items to display.
|
||||
* @slot separator - The separator to use between breadcrumb items. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlBreadcrumb extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private separatorDir = this.localize.dir();
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
|
||||
|
||||
/**
|
||||
* The label to use for the breadcrumb control. This will not be shown on the screen, but it will be announced by
|
||||
* screen readers and other assistive devices to provide more context for users.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
// Generates a clone of the separator element to use for each breadcrumb item
|
||||
private getSeparator() {
|
||||
const separator = this.separatorSlot.assignedElements({ flatten: true })[0] as HTMLElement;
|
||||
|
||||
// Clone it, remove ids, and slot it
|
||||
const clone = separator.cloneNode(true) as HTMLElement;
|
||||
[clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id'));
|
||||
clone.setAttribute('data-default', '');
|
||||
clone.slot = 'separator';
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const items = [...this.defaultSlot.assignedElements({ flatten: true })].filter(
|
||||
item => item.tagName.toLowerCase() === 'sl-breadcrumb-item'
|
||||
) as SlBreadcrumbItem[];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// Append separators to each item if they don't already have one
|
||||
const separator = item.querySelector('[slot="separator"]');
|
||||
if (separator === null) {
|
||||
// No separator exists, add one
|
||||
item.append(this.getSeparator());
|
||||
} else if (separator.hasAttribute('data-default')) {
|
||||
// A default separator exists, replace it
|
||||
separator.replaceWith(this.getSeparator());
|
||||
} else {
|
||||
// The user provided a custom separator, leave it alone
|
||||
}
|
||||
|
||||
// The last breadcrumb item is the "current page"
|
||||
if (index === items.length - 1) {
|
||||
item.setAttribute('aria-current', 'page');
|
||||
} else {
|
||||
item.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// We clone the separator and inject them into breadcrumb items, so we need to regenerate the default ones when
|
||||
// directionality changes. We do this by storing the current separator direction, waiting for render, then calling
|
||||
// the function that regenerates them.
|
||||
if (this.separatorDir !== this.localize.dir()) {
|
||||
this.separatorDir = this.localize.dir();
|
||||
this.updateComplete.then(() => this.handleSlotChange());
|
||||
}
|
||||
|
||||
return html`
|
||||
<nav part="base" class="breadcrumb" aria-label=${this.label}>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</nav>
|
||||
|
||||
<span hidden aria-hidden="true">
|
||||
<slot name="separator">
|
||||
<sl-icon name=${this.localize.dir() === 'rtl' ? 'chevron-left' : 'chevron-right'} library="system"></sl-icon>
|
||||
</slot>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-breadcrumb': SlBreadcrumb;
|
||||
}
|
||||
}
|
||||
@@ -1,104 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './breadcrumb.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item.js';
|
||||
|
||||
/**
|
||||
* @summary Breadcrumbs provide a group of links so users can easily navigate a website's hierarchy.
|
||||
* @documentation https://shoelace.style/components/breadcrumb
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more breadcrumb items to display.
|
||||
* @slot separator - The separator to use between breadcrumb items. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-breadcrumb')
|
||||
export default class SlBreadcrumb extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private separatorDir = this.localize.dir();
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
|
||||
|
||||
/**
|
||||
* The label to use for the breadcrumb control. This will not be shown on the screen, but it will be announced by
|
||||
* screen readers and other assistive devices to provide more context for users.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
// Generates a clone of the separator element to use for each breadcrumb item
|
||||
private getSeparator() {
|
||||
const separator = this.separatorSlot.assignedElements({ flatten: true })[0] as HTMLElement;
|
||||
|
||||
// Clone it, remove ids, and slot it
|
||||
const clone = separator.cloneNode(true) as HTMLElement;
|
||||
[clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id'));
|
||||
clone.setAttribute('data-default', '');
|
||||
clone.slot = 'separator';
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const items = [...this.defaultSlot.assignedElements({ flatten: true })].filter(
|
||||
item => item.tagName.toLowerCase() === 'sl-breadcrumb-item'
|
||||
) as SlBreadcrumbItem[];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// Append separators to each item if they don't already have one
|
||||
const separator = item.querySelector('[slot="separator"]');
|
||||
if (separator === null) {
|
||||
// No separator exists, add one
|
||||
item.append(this.getSeparator());
|
||||
} else if (separator.hasAttribute('data-default')) {
|
||||
// A default separator exists, replace it
|
||||
separator.replaceWith(this.getSeparator());
|
||||
} else {
|
||||
// The user provided a custom separator, leave it alone
|
||||
}
|
||||
|
||||
// The last breadcrumb item is the "current page"
|
||||
if (index === items.length - 1) {
|
||||
item.setAttribute('aria-current', 'page');
|
||||
} else {
|
||||
item.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// We clone the separator and inject them into breadcrumb items, so we need to regenerate the default ones when
|
||||
// directionality changes. We do this by storing the current separator direction, waiting for render, then calling
|
||||
// the function that regenerates them.
|
||||
if (this.separatorDir !== this.localize.dir()) {
|
||||
this.separatorDir = this.localize.dir();
|
||||
this.updateComplete.then(() => this.handleSlotChange());
|
||||
}
|
||||
|
||||
return html`
|
||||
<nav part="base" class="breadcrumb" aria-label=${this.label}>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</nav>
|
||||
|
||||
<slot name="separator" hidden aria-hidden="true">
|
||||
<sl-icon name=${this.localize.dir() === 'rtl' ? 'chevron-left' : 'chevron-right'} library="system"></sl-icon>
|
||||
</slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-breadcrumb': SlBreadcrumb;
|
||||
}
|
||||
}
|
||||
import SlBreadcrumb from './breadcrumb.component.js';
|
||||
export * from './breadcrumb.component.js';
|
||||
export default SlBreadcrumb;
|
||||
SlBreadcrumb.define('sl-breadcrumb');
|
||||
|
||||
97
src/components/button-group/button-group.component.ts
Normal file
97
src/components/button-group/button-group.component.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './button-group.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Button groups can be used to group related buttons into sections.
|
||||
* @documentation https://shoelace.style/components/button-group
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more `<sl-button>` elements to display in the button group.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlButtonGroup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
|
||||
@state() disableRole = false;
|
||||
|
||||
/**
|
||||
* A label to use for the button group. This won't be displayed on the screen, but it will be announced by assistive
|
||||
* devices when interacting with the control and is strongly recommended.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
private handleFocus(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleBlur(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleMouseOver(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
private handleMouseOut(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const slottedElements = [...this.defaultSlot.assignedElements({ flatten: true })] as HTMLElement[];
|
||||
|
||||
slottedElements.forEach(el => {
|
||||
const index = slottedElements.indexOf(el);
|
||||
const button = findButton(el);
|
||||
|
||||
if (button !== null) {
|
||||
button.classList.add('sl-button-group__button');
|
||||
button.classList.toggle('sl-button-group__button--first', index === 0);
|
||||
button.classList.toggle('sl-button-group__button--inner', index > 0 && index < slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--last', index === slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--radio', button.tagName.toLowerCase() === 'sl-radio-button');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line lit-a11y/mouse-events-have-key-events
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class="button-group"
|
||||
role="${this.disableRole ? 'presentation' : 'group'}"
|
||||
aria-label=${this.label}
|
||||
@focusout=${this.handleBlur}
|
||||
@focusin=${this.handleFocus}
|
||||
@mouseover=${this.handleMouseOver}
|
||||
@mouseout=${this.handleMouseOut}
|
||||
>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function findButton(el: HTMLElement) {
|
||||
const selector = 'sl-button, sl-radio-button';
|
||||
|
||||
// The button could be the target element or a child of it (e.g. a dropdown or tooltip anchor)
|
||||
return el.closest(selector) ?? el.querySelector(selector);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-button-group': SlButtonGroup;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,4 @@
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './button-group.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Button groups can be used to group related buttons into sections.
|
||||
* @documentation https://shoelace.style/components/button-group
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - One or more `<sl-button>` elements to display in the button group.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-button-group')
|
||||
export default class SlButtonGroup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
|
||||
@state() disableRole = false;
|
||||
|
||||
/**
|
||||
* A label to use for the button group. This won't be displayed on the screen, but it will be announced by assistive
|
||||
* devices when interacting with the control and is strongly recommended.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
private handleFocus(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleBlur(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--focus');
|
||||
}
|
||||
|
||||
private handleMouseOver(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.add('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
private handleMouseOut(event: Event) {
|
||||
const button = findButton(event.target as HTMLElement);
|
||||
button?.classList.remove('sl-button-group__button--hover');
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const slottedElements = [...this.defaultSlot.assignedElements({ flatten: true })] as HTMLElement[];
|
||||
|
||||
slottedElements.forEach(el => {
|
||||
const index = slottedElements.indexOf(el);
|
||||
const button = findButton(el);
|
||||
|
||||
if (button !== null) {
|
||||
button.classList.add('sl-button-group__button');
|
||||
button.classList.toggle('sl-button-group__button--first', index === 0);
|
||||
button.classList.toggle('sl-button-group__button--inner', index > 0 && index < slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--last', index === slottedElements.length - 1);
|
||||
button.classList.toggle('sl-button-group__button--radio', button.tagName.toLowerCase() === 'sl-radio-button');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line lit-a11y/mouse-events-have-key-events
|
||||
return html`
|
||||
<slot
|
||||
part="base"
|
||||
class="button-group"
|
||||
role="${this.disableRole ? 'presentation' : 'group'}"
|
||||
aria-label=${this.label}
|
||||
@focusout=${this.handleBlur}
|
||||
@focusin=${this.handleFocus}
|
||||
@mouseover=${this.handleMouseOver}
|
||||
@mouseout=${this.handleMouseOut}
|
||||
@slotchange=${this.handleSlotChange}
|
||||
></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function findButton(el: HTMLElement) {
|
||||
const selector = 'sl-button, sl-radio-button';
|
||||
|
||||
// The button could be the target element or a child of it (e.g. a dropdown or tooltip anchor)
|
||||
return el.closest(selector) ?? el.querySelector(selector);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-button-group': SlButtonGroup;
|
||||
}
|
||||
}
|
||||
import SlButtonGroup from './button-group.component.js';
|
||||
export * from './button-group.component.js';
|
||||
export default SlButtonGroup;
|
||||
SlButtonGroup.define('sl-button-group');
|
||||
|
||||
336
src/components/button/button.component.ts
Normal file
336
src/components/button/button.component.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { FormControlController, validValidityState } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import SlSpinner from '../spinner/spinner.component.js';
|
||||
import styles from './button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Buttons represent actions that are available to the user.
|
||||
* @documentation https://shoelace.style/components/button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
* @dependency sl-spinner
|
||||
*
|
||||
* @event sl-blur - Emitted when the button loses focus.
|
||||
* @event sl-focus - Emitted when the button gains focus.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @slot - The button's label.
|
||||
* @slot prefix - A presentational prefix icon or similar element.
|
||||
* @slot suffix - A presentational suffix icon or similar element.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart label - The button's label.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
* @csspart caret - The button's caret icon, an `<sl-icon>` element.
|
||||
* @csspart spinner - The spinner that shows when the button is in the loading state.
|
||||
*/
|
||||
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon,
|
||||
'sl-spinner': SlSpinner
|
||||
};
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
form: input => {
|
||||
// Buttons support a form attribute that points to an arbitrary form, so if this attribute is set we need to query
|
||||
// the form from the same root using its id
|
||||
if (input.hasAttribute('form')) {
|
||||
const doc = input.getRootNode() as Document | ShadowRoot;
|
||||
const formId = input.getAttribute('form')!;
|
||||
return doc.getElementById(formId) as HTMLFormElement;
|
||||
}
|
||||
|
||||
// Fall back to the closest containing form
|
||||
return input.closest('form');
|
||||
},
|
||||
assumeInteractionOn: ['click']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() invalid = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The button's theme variant. */
|
||||
@property({ reflect: true }) variant: 'default' | 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | 'text' =
|
||||
'default';
|
||||
|
||||
/** The button's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws the button with a caret. Used to indicate that the button triggers a dropdown menu or similar behavior. */
|
||||
@property({ type: Boolean, reflect: true }) caret = false;
|
||||
|
||||
/** Disables the button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Draws the button in a loading state. */
|
||||
@property({ type: Boolean, reflect: true }) loading = false;
|
||||
|
||||
/** Draws an outlined button. */
|
||||
@property({ type: Boolean, reflect: true }) outline = false;
|
||||
|
||||
/** Draws a pill-style button with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/**
|
||||
* Draws a circular icon button. When this attribute is present, the button expects a single `<sl-icon>` in the
|
||||
* default slot.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) circle = false;
|
||||
|
||||
/**
|
||||
* The type of button. Note that the default value is `button` instead of `submit`, which is opposite of how native
|
||||
* `<button>` elements behave. When the type is `submit`, the button will submit the surrounding form.
|
||||
*/
|
||||
@property() type: 'button' | 'submit' | 'reset' = 'button';
|
||||
|
||||
/**
|
||||
* The name of the button, submitted as a name/value pair with form data, but only when this button is the submitter.
|
||||
* This attribute is ignored when `href` is present.
|
||||
*/
|
||||
@property() name = '';
|
||||
|
||||
/**
|
||||
* The value of the button, submitted as a pair with the button's name as part of the form data, but only when this
|
||||
* button is the submitter. This attribute is ignored when `href` is present.
|
||||
*/
|
||||
@property() value = '';
|
||||
|
||||
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
||||
@property() href = '';
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is present. */
|
||||
@property() target: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/**
|
||||
* When using `href`, this attribute will map to the underlying link's `rel` attribute. Unlike regular links, the
|
||||
* default is `noreferrer noopener` to prevent security exploits. However, if you're using `target` to point to a
|
||||
* specific tab/window, this will prevent that from working correctly. You can remove or change the default value by
|
||||
* setting the attribute to an empty string or a value of your choice, respectively.
|
||||
*/
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
|
||||
@property() download?: string;
|
||||
|
||||
/**
|
||||
* The "form owner" to associate the button with. If omitted, the closest containing form will be used instead. The
|
||||
* value of this attribute must be an id of a form in the same document or shadow root as the button.
|
||||
*/
|
||||
@property() form: string;
|
||||
|
||||
/** Used to override the form owner's `action` attribute. */
|
||||
@property({ attribute: 'formaction' }) formAction: string;
|
||||
|
||||
/** Used to override the form owner's `enctype` attribute. */
|
||||
@property({ attribute: 'formenctype' })
|
||||
formEnctype: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
|
||||
|
||||
/** Used to override the form owner's `method` attribute. */
|
||||
@property({ attribute: 'formmethod' }) formMethod: 'post' | 'get';
|
||||
|
||||
/** Used to override the form owner's `novalidate` attribute. */
|
||||
@property({ attribute: 'formnovalidate', type: Boolean }) formNoValidate: boolean;
|
||||
|
||||
/** Used to override the form owner's `target` attribute. */
|
||||
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validity;
|
||||
}
|
||||
|
||||
return validValidityState;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validationMessage;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
if (this.isButton()) {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.type === 'submit') {
|
||||
this.formControlController.submit(this);
|
||||
}
|
||||
|
||||
if (this.type === 'reset') {
|
||||
this.formControlController.reset(this);
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private isButton() {
|
||||
return this.href ? false : true;
|
||||
}
|
||||
|
||||
private isLink() {
|
||||
return this.href ? true : false;
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
if (this.isButton()) {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulates a click on the button. */
|
||||
click() {
|
||||
this.button.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.button.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the button. */
|
||||
blur() {
|
||||
this.button.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).checkValidity();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).reportValidity();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
if (this.isButton()) {
|
||||
(this.button as HTMLButtonElement).setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLink = this.isLink();
|
||||
const tag = isLink ? literal`a` : literal`button`;
|
||||
|
||||
/* eslint-disable lit/no-invalid-html */
|
||||
/* eslint-disable lit/binding-positions */
|
||||
return html`
|
||||
<${tag}
|
||||
part="base"
|
||||
class=${classMap({
|
||||
button: true,
|
||||
'button--default': this.variant === 'default',
|
||||
'button--primary': this.variant === 'primary',
|
||||
'button--success': this.variant === 'success',
|
||||
'button--neutral': this.variant === 'neutral',
|
||||
'button--warning': this.variant === 'warning',
|
||||
'button--danger': this.variant === 'danger',
|
||||
'button--text': this.variant === 'text',
|
||||
'button--small': this.size === 'small',
|
||||
'button--medium': this.size === 'medium',
|
||||
'button--large': this.size === 'large',
|
||||
'button--caret': this.caret,
|
||||
'button--circle': this.circle,
|
||||
'button--disabled': this.disabled,
|
||||
'button--focused': this.hasFocus,
|
||||
'button--loading': this.loading,
|
||||
'button--standard': !this.outline,
|
||||
'button--outline': this.outline,
|
||||
'button--pill': this.pill,
|
||||
'button--rtl': this.localize.dir() === 'rtl',
|
||||
'button--has-label': this.hasSlotController.test('[default]'),
|
||||
'button--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'button--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
|
||||
type=${ifDefined(isLink ? undefined : this.type)}
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${ifDefined(isLink ? undefined : this.name)}
|
||||
value=${ifDefined(isLink ? undefined : this.value)}
|
||||
href=${ifDefined(isLink ? this.href : undefined)}
|
||||
target=${ifDefined(isLink ? this.target : undefined)}
|
||||
download=${ifDefined(isLink ? this.download : undefined)}
|
||||
rel=${ifDefined(isLink ? this.rel : undefined)}
|
||||
role=${ifDefined(isLink ? undefined : 'button')}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@invalid=${this.isButton() ? this.handleInvalid : null}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="button__prefix"></slot>
|
||||
<slot part="label" class="button__label"></slot>
|
||||
<slot name="suffix" part="suffix" class="button__suffix"></slot>
|
||||
${
|
||||
this.caret ? html` <sl-icon part="caret" class="button__caret" library="system" name="caret"></sl-icon> ` : ''
|
||||
}
|
||||
${this.loading ? html`<sl-spinner part="spinner"></sl-spinner>` : ''}
|
||||
</${tag}>
|
||||
`;
|
||||
/* eslint-enable lit/no-invalid-html */
|
||||
/* eslint-enable lit/binding-positions */
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-button': SlButton;
|
||||
}
|
||||
}
|
||||
@@ -68,27 +68,6 @@ describe('<sl-button>', () => {
|
||||
const el = await fixture<SlButton>(html` <sl-button href="some/path" disabled>Button Label</sl-button> `);
|
||||
expect(el.shadowRoot!.querySelector('a[disabled]')).not.to.exist;
|
||||
});
|
||||
|
||||
it('should not bubble up clicks', async () => {
|
||||
const button = await fixture<SlButton>(html` <sl-button disabled>Button Label</sl-button> `);
|
||||
const handleClick = sinon.spy();
|
||||
button.addEventListener('click', handleClick);
|
||||
button.click();
|
||||
|
||||
expect(handleClick).not.to.have.been.called;
|
||||
|
||||
button.shadowRoot!.querySelector('button')!.click();
|
||||
expect(handleClick).not.to.have.been.called;
|
||||
|
||||
const buttonLink = await fixture<SlButton>(html` <sl-button href="some/path" disabled>Button Label</sl-button> `);
|
||||
buttonLink.addEventListener('click', handleClick);
|
||||
buttonLink.click();
|
||||
|
||||
expect(handleClick).not.to.have.been.called;
|
||||
|
||||
buttonLink.shadowRoot!.querySelector('a')!.click();
|
||||
expect(handleClick).not.to.have.been.called;
|
||||
});
|
||||
});
|
||||
|
||||
it('should have title if title attribute is set', async () => {
|
||||
|
||||
@@ -1,345 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import '../spinner/spinner.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { FormControlController, validValidityState } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Buttons represent actions that are available to the user.
|
||||
* @documentation https://shoelace.style/components/button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
* @dependency sl-spinner
|
||||
*
|
||||
* @event sl-blur - Emitted when the button loses focus.
|
||||
* @event sl-focus - Emitted when the button gains focus.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @slot - The button's label.
|
||||
* @slot prefix - A presentational prefix icon or similar element.
|
||||
* @slot suffix - A presentational suffix icon or similar element.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart label - The button's label.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
* @csspart caret - The button's caret icon, an `<sl-icon>` element.
|
||||
*/
|
||||
@customElement('sl-button')
|
||||
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
form: input => {
|
||||
// Buttons support a form attribute that points to an arbitrary form, so if this attribute is set we need to query
|
||||
// the form from the same root using its id
|
||||
if (input.hasAttribute('form')) {
|
||||
const doc = input.getRootNode() as Document | ShadowRoot;
|
||||
const formId = input.getAttribute('form')!;
|
||||
return doc.getElementById(formId) as HTMLFormElement;
|
||||
}
|
||||
|
||||
// Fall back to the closest containing form
|
||||
return input.closest('form');
|
||||
},
|
||||
assumeInteractionOn: ['click']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() invalid = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The button's theme variant. */
|
||||
@property({ reflect: true }) variant: 'default' | 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | 'text' =
|
||||
'default';
|
||||
|
||||
/** The button's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws the button with a caret. Used to indicate that the button triggers a dropdown menu or similar behavior. */
|
||||
@property({ type: Boolean, reflect: true }) caret = false;
|
||||
|
||||
/** Disables the button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Draws the button in a loading state. */
|
||||
@property({ type: Boolean, reflect: true }) loading = false;
|
||||
|
||||
/** Draws an outlined button. */
|
||||
@property({ type: Boolean, reflect: true }) outline = false;
|
||||
|
||||
/** Draws a pill-style button with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/**
|
||||
* Draws a circular icon button. When this attribute is present, the button expects a single `<sl-icon>` in the
|
||||
* default slot.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) circle = false;
|
||||
|
||||
/**
|
||||
* The type of button. Note that the default value is `button` instead of `submit`, which is opposite of how native
|
||||
* `<button>` elements behave. When the type is `submit`, the button will submit the surrounding form.
|
||||
*/
|
||||
@property() type: 'button' | 'submit' | 'reset' = 'button';
|
||||
|
||||
/**
|
||||
* The name of the button, submitted as a name/value pair with form data, but only when this button is the submitter.
|
||||
* This attribute is ignored when `href` is present.
|
||||
*/
|
||||
@property() name = '';
|
||||
|
||||
/**
|
||||
* The value of the button, submitted as a pair with the button's name as part of the form data, but only when this
|
||||
* button is the submitter. This attribute is ignored when `href` is present.
|
||||
*/
|
||||
@property() value = '';
|
||||
|
||||
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
||||
@property() href = '';
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is present. */
|
||||
@property() target: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/**
|
||||
* When using `href`, this attribute will map to the underlying link's `rel` attribute. Unlike regular links, the
|
||||
* default is `noreferrer noopener` to prevent security exploits. However, if you're using `target` to point to a
|
||||
* specific tab/window, this will prevent that from working correctly. You can remove or change the default value by
|
||||
* setting the attribute to an empty string or a value of your choice, respectively.
|
||||
*/
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
|
||||
@property() download?: string;
|
||||
|
||||
/**
|
||||
* The "form owner" to associate the button with. If omitted, the closest containing form will be used instead. The
|
||||
* value of this attribute must be an id of a form in the same document or shadow root as the button.
|
||||
*/
|
||||
@property() form: string;
|
||||
|
||||
/** Used to override the form owner's `action` attribute. */
|
||||
@property({ attribute: 'formaction' }) formAction: string;
|
||||
|
||||
/** Used to override the form owner's `enctype` attribute. */
|
||||
@property({ attribute: 'formenctype' })
|
||||
formEnctype: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
|
||||
|
||||
/** Used to override the form owner's `method` attribute. */
|
||||
@property({ attribute: 'formmethod' }) formMethod: 'post' | 'get';
|
||||
|
||||
/** Used to override the form owner's `novalidate` attribute. */
|
||||
@property({ attribute: 'formnovalidate', type: Boolean }) formNoValidate: boolean;
|
||||
|
||||
/** Used to override the form owner's `target` attribute. */
|
||||
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validity;
|
||||
}
|
||||
|
||||
return validValidityState;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validationMessage;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener('click', this.handleHostClick);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
if (this.isButton()) {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.type === 'submit') {
|
||||
this.formControlController.submit(this);
|
||||
}
|
||||
|
||||
if (this.type === 'reset') {
|
||||
this.formControlController.reset(this);
|
||||
}
|
||||
}
|
||||
|
||||
private handleHostClick = (event: MouseEvent) => {
|
||||
// Prevent the click event from being emitted when the button is disabled or loading
|
||||
if (this.disabled || this.loading) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private isButton() {
|
||||
return this.href ? false : true;
|
||||
}
|
||||
|
||||
private isLink() {
|
||||
return this.href ? true : false;
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
if (this.isButton()) {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulates a click on the button. */
|
||||
click() {
|
||||
this.button.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.button.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the button. */
|
||||
blur() {
|
||||
this.button.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).checkValidity();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).reportValidity();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
if (this.isButton()) {
|
||||
(this.button as HTMLButtonElement).setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLink = this.isLink();
|
||||
const tag = isLink ? literal`a` : literal`button`;
|
||||
|
||||
/* eslint-disable lit/no-invalid-html */
|
||||
/* eslint-disable lit/binding-positions */
|
||||
return html`
|
||||
<${tag}
|
||||
part="base"
|
||||
class=${classMap({
|
||||
button: true,
|
||||
'button--default': this.variant === 'default',
|
||||
'button--primary': this.variant === 'primary',
|
||||
'button--success': this.variant === 'success',
|
||||
'button--neutral': this.variant === 'neutral',
|
||||
'button--warning': this.variant === 'warning',
|
||||
'button--danger': this.variant === 'danger',
|
||||
'button--text': this.variant === 'text',
|
||||
'button--small': this.size === 'small',
|
||||
'button--medium': this.size === 'medium',
|
||||
'button--large': this.size === 'large',
|
||||
'button--caret': this.caret,
|
||||
'button--circle': this.circle,
|
||||
'button--disabled': this.disabled,
|
||||
'button--focused': this.hasFocus,
|
||||
'button--loading': this.loading,
|
||||
'button--standard': !this.outline,
|
||||
'button--outline': this.outline,
|
||||
'button--pill': this.pill,
|
||||
'button--rtl': this.localize.dir() === 'rtl',
|
||||
'button--has-label': this.hasSlotController.test('[default]'),
|
||||
'button--has-prefix': this.hasSlotController.test('prefix'),
|
||||
'button--has-suffix': this.hasSlotController.test('suffix')
|
||||
})}
|
||||
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
|
||||
type=${ifDefined(isLink ? undefined : this.type)}
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${ifDefined(isLink ? undefined : this.name)}
|
||||
value=${ifDefined(isLink ? undefined : this.value)}
|
||||
href=${ifDefined(isLink ? this.href : undefined)}
|
||||
target=${ifDefined(isLink ? this.target : undefined)}
|
||||
download=${ifDefined(isLink ? this.download : undefined)}
|
||||
rel=${ifDefined(isLink ? this.rel : undefined)}
|
||||
role=${ifDefined(isLink ? undefined : 'button')}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@invalid=${this.isButton() ? this.handleInvalid : null}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="button__prefix"></slot>
|
||||
<slot part="label" class="button__label"></slot>
|
||||
<slot name="suffix" part="suffix" class="button__suffix"></slot>
|
||||
${
|
||||
this.caret ? html` <sl-icon part="caret" class="button__caret" library="system" name="caret"></sl-icon> ` : ''
|
||||
}
|
||||
${this.loading ? html`<sl-spinner></sl-spinner>` : ''}
|
||||
</${tag}>
|
||||
`;
|
||||
/* eslint-enable lit/no-invalid-html */
|
||||
/* eslint-enable lit/binding-positions */
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-button': SlButton;
|
||||
}
|
||||
}
|
||||
import SlButton from './button.component.js';
|
||||
export * from './button.component.js';
|
||||
export default SlButton;
|
||||
SlButton.define('sl-button');
|
||||
|
||||
59
src/components/card/card.component.ts
Normal file
59
src/components/card/card.component.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './card.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Cards can be used to group related subjects in a container.
|
||||
* @documentation https://shoelace.style/components/card
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The card's main content.
|
||||
* @slot header - An optional header for the card.
|
||||
* @slot footer - An optional footer for the card.
|
||||
* @slot image - An optional image to render at the start of the card.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart image - The container that wraps the card's image.
|
||||
* @csspart header - The container that wraps the card's header.
|
||||
* @csspart body - The container that wraps the card's main content.
|
||||
* @csspart footer - The container that wraps the card's footer.
|
||||
*
|
||||
* @cssproperty --border-color - The card's border color, including borders that occur inside the card.
|
||||
* @cssproperty --border-radius - The border radius for the card's edges.
|
||||
* @cssproperty --border-width - The width of the card's borders.
|
||||
* @cssproperty --padding - The padding to use for the card's sections.
|
||||
*/
|
||||
export default class SlCard extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
card: true,
|
||||
'card--has-footer': this.hasSlotController.test('footer'),
|
||||
'card--has-image': this.hasSlotController.test('image'),
|
||||
'card--has-header': this.hasSlotController.test('header')
|
||||
})}
|
||||
>
|
||||
<slot name="image" part="image" class="card__image"></slot>
|
||||
<slot name="header" part="header" class="card__header"></slot>
|
||||
<slot part="body" class="card__body"></slot>
|
||||
<slot name="footer" part="footer" class="card__footer"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-card': SlCard;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,4 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './card.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Cards can be used to group related subjects in a container.
|
||||
* @documentation https://shoelace.style/components/card
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The card's main content.
|
||||
* @slot header - An optional header for the card.
|
||||
* @slot footer - An optional footer for the card.
|
||||
* @slot image - An optional image to render at the start of the card.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart image - The container that wraps the card's image.
|
||||
* @csspart header - The container that wraps the card's header.
|
||||
* @csspart body - The container that wraps the card's main content.
|
||||
* @csspart footer - The container that wraps the card's footer.
|
||||
*
|
||||
* @cssproperty --border-color - The card's border color, including borders that occur inside the card.
|
||||
* @cssproperty --border-radius - The border radius for the card's edges.
|
||||
* @cssproperty --border-width - The width of the card's borders.
|
||||
* @cssproperty --padding - The padding to use for the card's sections.
|
||||
*/
|
||||
@customElement('sl-card')
|
||||
export default class SlCard extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
card: true,
|
||||
'card--has-footer': this.hasSlotController.test('footer'),
|
||||
'card--has-image': this.hasSlotController.test('image'),
|
||||
'card--has-header': this.hasSlotController.test('header')
|
||||
})}
|
||||
>
|
||||
<slot name="image" part="image" class="card__image"></slot>
|
||||
<slot name="header" part="header" class="card__header"></slot>
|
||||
<slot part="body" class="card__body"></slot>
|
||||
<slot name="footer" part="footer" class="card__footer"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-card': SlCard;
|
||||
}
|
||||
}
|
||||
import SlCard from './card.component.js';
|
||||
export * from './card.component.js';
|
||||
export default SlCard;
|
||||
SlCard.define('sl-card');
|
||||
|
||||
38
src/components/carousel-item/carousel-item.component.ts
Normal file
38
src/components/carousel-item/carousel-item.component.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './carousel-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary A carousel item represent a slide within a [carousel](/components/carousel).
|
||||
*
|
||||
* @since 2.0
|
||||
* @status experimental
|
||||
*
|
||||
* @slot - The carousel item's content..
|
||||
*
|
||||
* @cssproperty --aspect-ratio - The slide's aspect ratio. Inherited from the carousel by default.
|
||||
*
|
||||
*/
|
||||
export default class SlCarouselItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
static isCarouselItem(node: Node) {
|
||||
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'group');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel-item': SlCarouselItem;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,4 @@
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './carousel-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary A carousel item represent a slide within a [carousel](/components/carousel).
|
||||
*
|
||||
* @since 2.0
|
||||
* @status experimental
|
||||
*
|
||||
* @slot - The carousel item's content..
|
||||
*
|
||||
* @cssproperty --aspect-ratio - The slide's aspect ratio. Inherited from the carousel by default.
|
||||
*
|
||||
*/
|
||||
@customElement('sl-carousel-item')
|
||||
export default class SlCarouselItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
static isCarouselItem(node: Node) {
|
||||
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'group');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel-item': SlCarouselItem;
|
||||
}
|
||||
}
|
||||
import SlCarouselItem from './carousel-item.component.js';
|
||||
export * from './carousel-item.component.js';
|
||||
export default SlCarouselItem;
|
||||
SlCarouselItem.define('sl-carousel-item');
|
||||
|
||||
479
src/components/carousel/carousel.component.ts
Normal file
479
src/components/carousel/carousel.component.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { AutoplayController } from './autoplay-controller.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { map } from 'lit/directives/map.js';
|
||||
import { prefersReducedMotion } from '../../internal/animate.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { range } from 'lit/directives/range.js';
|
||||
import { ScrollController } from './scroll-controller.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlCarouselItem from '../carousel-item/carousel-item.component.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './carousel.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
|
||||
*
|
||||
* @since 2.2
|
||||
* @status experimental
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event {{ index: number, slide: SlCarouselItem }} sl-slide-change - Emitted when the active slide changes.
|
||||
*
|
||||
* @slot - The carousel's main content, one or more `<sl-carousel-item>` elements.
|
||||
* @slot next-icon - Optional next icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot previous-icon - Optional previous icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @csspart base - The carousel's internal wrapper.
|
||||
* @csspart scroll-container - The scroll container that wraps the slides.
|
||||
* @csspart pagination - The pagination indicators wrapper.
|
||||
* @csspart pagination-item - The pagination indicator.
|
||||
* @csspart pagination-item--active - Applied when the item is active.
|
||||
* @csspart navigation - The navigation wrapper.
|
||||
* @csspart navigation-button - The navigation button.
|
||||
* @csspart navigation-button--previous - Applied to the previous button.
|
||||
* @csspart navigation-button--next - Applied to the next button.
|
||||
*
|
||||
* @cssproperty --slide-gap - The space between each slide.
|
||||
* @cssproperty --aspect-ratio - The aspect ratio of each slide.
|
||||
* @cssproperty --scroll-hint - The amount of padding to apply to the scroll area, allowing adjacent slides to become
|
||||
* partially visible as a scroll hint.
|
||||
*/
|
||||
export default class SlCarousel extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
|
||||
@property({ type: Boolean, reflect: true }) loop = false;
|
||||
|
||||
/** When set, show the carousel's navigation. */
|
||||
@property({ type: Boolean, reflect: true }) navigation = false;
|
||||
|
||||
/** When set, show the carousel's pagination indicators. */
|
||||
@property({ type: Boolean, reflect: true }) pagination = false;
|
||||
|
||||
/** When set, the slides will scroll automatically when the user is not interacting with them. */
|
||||
@property({ type: Boolean, reflect: true }) autoplay = false;
|
||||
|
||||
/** Specifies the amount of time, in milliseconds, between each automatic scroll. */
|
||||
@property({ type: Number, attribute: 'autoplay-interval' }) autoplayInterval = 3000;
|
||||
|
||||
/** Specifies how many slides should be shown at a given time. */
|
||||
@property({ type: Number, attribute: 'slides-per-page' }) slidesPerPage = 1;
|
||||
|
||||
/**
|
||||
* Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page`
|
||||
* greater than one.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
|
||||
|
||||
/** Specifies the orientation in which the carousel will lay out. */
|
||||
@property() orientation: 'horizontal' | 'vertical' = 'horizontal';
|
||||
|
||||
/** When set, it is possible to scroll through the slides by dragging them with the mouse. */
|
||||
@property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.carousel__slides') scrollContainer: HTMLElement;
|
||||
@query('.carousel__pagination') paginationContainer: HTMLElement;
|
||||
|
||||
// The index of the active slide
|
||||
@state() activeSlide = 0;
|
||||
|
||||
private autoplayController = new AutoplayController(this, () => this.next());
|
||||
private scrollController = new ScrollController(this);
|
||||
private readonly slides = this.getElementsByTagName('sl-carousel-item');
|
||||
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
|
||||
// A map containing the state of all the slides
|
||||
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private mutationObserver: MutationObserver;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'region');
|
||||
this.setAttribute('aria-label', this.localize.term('carousel'));
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach(entry => {
|
||||
// Store all the entries in a map to be processed when scrolling ends
|
||||
this.intersectionObserverEntries.set(entry.target, entry);
|
||||
|
||||
const slide = entry.target;
|
||||
slide.toggleAttribute('inert', !entry.isIntersecting);
|
||||
slide.classList.toggle('--in-view', entry.isIntersecting);
|
||||
slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true');
|
||||
});
|
||||
},
|
||||
{
|
||||
root: this,
|
||||
threshold: 0.6
|
||||
}
|
||||
);
|
||||
this.intersectionObserver = intersectionObserver;
|
||||
|
||||
// Store the initial state of each slide
|
||||
intersectionObserver.takeRecords().forEach(entry => {
|
||||
this.intersectionObserverEntries.set(entry.target, entry);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.intersectionObserver.disconnect();
|
||||
this.mutationObserver.disconnect();
|
||||
}
|
||||
|
||||
protected firstUpdated(): void {
|
||||
this.initializeSlides();
|
||||
this.mutationObserver = new MutationObserver(this.handleSlotChange);
|
||||
this.mutationObserver.observe(this, { childList: true, subtree: false });
|
||||
}
|
||||
|
||||
private getPageCount() {
|
||||
return Math.ceil(this.getSlides().length / this.slidesPerPage);
|
||||
}
|
||||
|
||||
private getCurrentPage() {
|
||||
return Math.ceil(this.activeSlide / this.slidesPerPage);
|
||||
}
|
||||
|
||||
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
|
||||
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
||||
const target = event.target as HTMLElement;
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const isFocusInPagination = target.closest('[part~="pagination-item"]') !== null;
|
||||
const isNext =
|
||||
event.key === 'ArrowDown' || (!isRtl && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft');
|
||||
const isPrevious =
|
||||
event.key === 'ArrowUp' || (!isRtl && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight');
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (isPrevious) {
|
||||
this.previous();
|
||||
}
|
||||
|
||||
if (isNext) {
|
||||
this.next();
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
this.goToSlide(0);
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
this.goToSlide(this.getSlides().length - 1);
|
||||
}
|
||||
|
||||
if (isFocusInPagination) {
|
||||
this.updateComplete.then(() => {
|
||||
const activePaginationItem = this.shadowRoot?.querySelector<HTMLButtonElement>(
|
||||
'[part~="pagination-item--active"]'
|
||||
);
|
||||
|
||||
if (activePaginationItem) {
|
||||
activePaginationItem.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleScrollEnd() {
|
||||
const slides = this.getSlides();
|
||||
const entries = [...this.intersectionObserverEntries.values()];
|
||||
|
||||
const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting);
|
||||
|
||||
if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) {
|
||||
const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone'));
|
||||
|
||||
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
|
||||
this.goToSlide(clonePosition, 'auto');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate the first intersecting slide
|
||||
if (firstIntersecting) {
|
||||
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlotChange = (mutations: MutationRecord[]) => {
|
||||
const needsInitialization = mutations.some(mutation =>
|
||||
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
||||
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
|
||||
)
|
||||
);
|
||||
|
||||
// Reinitialize the carousel if a carousel item has been added or removed
|
||||
if (needsInitialization) {
|
||||
this.initializeSlides();
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
@watch('loop', { waitUntilFirstUpdate: true })
|
||||
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
||||
initializeSlides() {
|
||||
const slides = this.getSlides();
|
||||
const intersectionObserver = this.intersectionObserver;
|
||||
|
||||
this.intersectionObserverEntries.clear();
|
||||
|
||||
// Removes all the cloned elements from the carousel
|
||||
this.getSlides({ excludeClones: false }).forEach((slide, index) => {
|
||||
intersectionObserver.unobserve(slide);
|
||||
|
||||
slide.classList.remove('--in-view');
|
||||
slide.classList.remove('--is-active');
|
||||
slide.setAttribute('aria-label', this.localize.term('slideNum', index + 1));
|
||||
|
||||
if (slide.hasAttribute('data-clone')) {
|
||||
slide.remove();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.loop) {
|
||||
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
|
||||
const slidesPerPage = this.slidesPerPage;
|
||||
const lastSlides = slides.slice(-slidesPerPage);
|
||||
const firstSlides = slides.slice(0, slidesPerPage);
|
||||
|
||||
lastSlides.reverse().forEach((slide, i) => {
|
||||
const clone = slide.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute('data-clone', String(slides.length - i - 1));
|
||||
this.prepend(clone);
|
||||
});
|
||||
|
||||
firstSlides.forEach((slide, i) => {
|
||||
const clone = slide.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute('data-clone', String(i));
|
||||
this.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||
intersectionObserver.observe(slide);
|
||||
});
|
||||
|
||||
// Because the DOM may be changed, restore the scroll position to the active slide
|
||||
this.goToSlide(this.activeSlide, 'auto');
|
||||
}
|
||||
|
||||
@watch('activeSlide')
|
||||
handelSlideChange() {
|
||||
const slides = this.getSlides();
|
||||
slides.forEach((slide, i) => {
|
||||
slide.classList.toggle('--is-active', i === this.activeSlide);
|
||||
});
|
||||
|
||||
// Do not emit an event on first render
|
||||
if (this.hasUpdated) {
|
||||
this.emit('sl-slide-change', {
|
||||
detail: {
|
||||
index: this.activeSlide,
|
||||
slide: slides[this.activeSlide]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@watch('slidesPerMove')
|
||||
handleSlidesPerMoveChange() {
|
||||
const slides = this.getSlides({ excludeClones: false });
|
||||
|
||||
const slidesPerMove = this.slidesPerMove;
|
||||
slides.forEach((slide, i) => {
|
||||
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
|
||||
if (shouldSnap) {
|
||||
slide.style.removeProperty('scroll-snap-align');
|
||||
} else {
|
||||
slide.style.setProperty('scroll-snap-align', 'none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@watch('autoplay')
|
||||
handleAutoplayChange() {
|
||||
this.autoplayController.stop();
|
||||
if (this.autoplay) {
|
||||
this.autoplayController.start(this.autoplayInterval);
|
||||
}
|
||||
}
|
||||
|
||||
@watch('mouseDragging')
|
||||
handleMouseDraggingChange() {
|
||||
this.scrollController.mouseDragging = this.mouseDragging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the carousel backward by `slides-per-move` slides.
|
||||
*
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
previous(behavior: ScrollBehavior = 'smooth') {
|
||||
let previousIndex = this.activeSlide || this.activeSlide - this.slidesPerMove;
|
||||
let canSnap = false;
|
||||
|
||||
while (!canSnap && previousIndex > 0) {
|
||||
previousIndex -= 1;
|
||||
canSnap = Math.abs(previousIndex - this.slidesPerMove) % this.slidesPerMove === 0;
|
||||
}
|
||||
|
||||
this.goToSlide(previousIndex, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the carousel forward by `slides-per-move` slides.
|
||||
*
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
next(behavior: ScrollBehavior = 'smooth') {
|
||||
this.goToSlide(this.activeSlide + this.slidesPerMove, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the carousel to the slide specified by `index`.
|
||||
*
|
||||
* @param index - The slide index.
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
|
||||
const { slidesPerPage, loop, scrollContainer } = this;
|
||||
|
||||
const slides = this.getSlides();
|
||||
const slidesWithClones = this.getSlides({ excludeClones: false });
|
||||
|
||||
// Sets the next index without taking into account clones, if any.
|
||||
const newActiveSlide = (index + slides.length) % slides.length;
|
||||
this.activeSlide = newActiveSlide;
|
||||
|
||||
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
|
||||
// to normalize the starting index in order to ignore the first nth clones.
|
||||
const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
|
||||
const nextSlide = slidesWithClones[nextSlideIndex];
|
||||
|
||||
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const nextSlideRect = nextSlide.getBoundingClientRect();
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft,
|
||||
top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop,
|
||||
behavior: prefersReducedMotion() ? 'auto' : behavior
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { scrollController, slidesPerPage } = this;
|
||||
const pagesCount = this.getPageCount();
|
||||
const currentPage = this.getCurrentPage();
|
||||
const prevEnabled = this.loop || currentPage > 0;
|
||||
const nextEnabled = this.loop || currentPage < pagesCount - 1;
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
|
||||
return html`
|
||||
<div part="base" class="carousel">
|
||||
<div
|
||||
id="scroll-container"
|
||||
part="scroll-container"
|
||||
class="${classMap({
|
||||
carousel__slides: true,
|
||||
'carousel__slides--horizontal': this.orientation === 'horizontal',
|
||||
'carousel__slides--vertical': this.orientation === 'vertical'
|
||||
})}"
|
||||
style="--slides-per-page: ${this.slidesPerPage};"
|
||||
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
|
||||
aria-atomic="true"
|
||||
tabindex="0"
|
||||
@keydown=${this.handleKeyDown}
|
||||
@scrollend=${this.handleScrollEnd}
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
${this.navigation
|
||||
? html`
|
||||
<div part="navigation" class="carousel__navigation">
|
||||
<button
|
||||
part="navigation-button navigation-button--previous"
|
||||
class="${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--previous': true,
|
||||
'carousel__navigation-button--disabled': !prevEnabled
|
||||
})}"
|
||||
aria-label="${this.localize.term('previousSlide')}"
|
||||
aria-controls="scroll-container"
|
||||
aria-disabled="${prevEnabled ? 'false' : 'true'}"
|
||||
@click=${prevEnabled ? () => this.previous() : null}
|
||||
>
|
||||
<slot name="previous-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<button
|
||||
part="navigation-button navigation-button--next"
|
||||
class=${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--next': true,
|
||||
'carousel__navigation-button--disabled': !nextEnabled
|
||||
})}
|
||||
aria-label="${this.localize.term('nextSlide')}"
|
||||
aria-controls="scroll-container"
|
||||
aria-disabled="${nextEnabled ? 'false' : 'true'}"
|
||||
@click=${nextEnabled ? () => this.next() : null}
|
||||
>
|
||||
<slot name="next-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${this.pagination
|
||||
? html`
|
||||
<div part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
|
||||
${map(range(pagesCount), index => {
|
||||
const isActive = index === currentPage;
|
||||
return html`
|
||||
<button
|
||||
part="pagination-item ${isActive ? 'pagination-item--active' : ''}"
|
||||
class="${classMap({
|
||||
'carousel__pagination-item': true,
|
||||
'carousel__pagination-item--active': isActive
|
||||
})}"
|
||||
role="tab"
|
||||
aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
||||
tabindex=${isActive ? '0' : '-1'}
|
||||
@click=${() => this.goToSlide(index * slidesPerPage)}
|
||||
@keydown=${this.handleKeyDown}
|
||||
></button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel': SlCarousel;
|
||||
}
|
||||
}
|
||||
@@ -1,471 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { AutoplayController } from './autoplay-controller.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { map } from 'lit/directives/map.js';
|
||||
import { prefersReducedMotion } from '../../internal/animate.js';
|
||||
import { range } from 'lit/directives/range.js';
|
||||
import { ScrollController } from './scroll-controller.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlCarouselItem from '../carousel-item/carousel-item.js';
|
||||
import styles from './carousel.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
|
||||
*
|
||||
* @since 2.2
|
||||
* @status experimental
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event {{ index: number, slide: SlCarouselItem }} sl-slide-change - Emitted when the active slide changes.
|
||||
*
|
||||
* @slot - The carousel's main content, one or more `<sl-carousel-item>` elements.
|
||||
* @slot next-icon - Optional next icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot previous-icon - Optional previous icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @csspart base - The carousel's internal wrapper.
|
||||
* @csspart scroll-container - The scroll container that wraps the slides.
|
||||
* @csspart pagination - The pagination indicators wrapper.
|
||||
* @csspart pagination-item - The pagination indicator.
|
||||
* @csspart pagination-item--active - Applied when the item is active.
|
||||
* @csspart navigation - The navigation wrapper.
|
||||
* @csspart navigation-button - The navigation button.
|
||||
* @csspart navigation-button--previous - Applied to the previous button.
|
||||
* @csspart navigation-button--next - Applied to the next button.
|
||||
*
|
||||
* @cssproperty --slide-gap - The space between each slide.
|
||||
* @cssproperty --aspect-ratio - The aspect ratio of each slide.
|
||||
* @cssproperty --scroll-hint - The amount of padding to apply to the scroll area, allowing adjacent slides to become
|
||||
* partially visible as a scroll hint.
|
||||
*/
|
||||
@customElement('sl-carousel')
|
||||
export default class SlCarousel extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
|
||||
@property({ type: Boolean, reflect: true }) loop = false;
|
||||
|
||||
/** When set, show the carousel's navigation. */
|
||||
@property({ type: Boolean, reflect: true }) navigation = false;
|
||||
|
||||
/** When set, show the carousel's pagination indicators. */
|
||||
@property({ type: Boolean, reflect: true }) pagination = false;
|
||||
|
||||
/** When set, the slides will scroll automatically when the user is not interacting with them. */
|
||||
@property({ type: Boolean, reflect: true }) autoplay = false;
|
||||
|
||||
/** Specifies the amount of time, in milliseconds, between each automatic scroll. */
|
||||
@property({ type: Number, attribute: 'autoplay-interval' }) autoplayInterval = 3000;
|
||||
|
||||
/** Specifies how many slides should be shown at a given time. */
|
||||
@property({ type: Number, attribute: 'slides-per-page' }) slidesPerPage = 1;
|
||||
|
||||
/**
|
||||
* Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page`
|
||||
* greater than one.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
|
||||
|
||||
/** Specifies the orientation in which the carousel will lay out. */
|
||||
@property() orientation: 'horizontal' | 'vertical' = 'horizontal';
|
||||
|
||||
/** When set, it is possible to scroll through the slides by dragging them with the mouse. */
|
||||
@property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.carousel__slides') scrollContainer: HTMLElement;
|
||||
@query('.carousel__pagination') paginationContainer: HTMLElement;
|
||||
|
||||
// The index of the active slide
|
||||
@state() activeSlide = 0;
|
||||
|
||||
private autoplayController = new AutoplayController(this, () => this.next());
|
||||
private scrollController = new ScrollController(this);
|
||||
private readonly slides = this.getElementsByTagName('sl-carousel-item');
|
||||
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
|
||||
// A map containing the state of all the slides
|
||||
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private mutationObserver: MutationObserver;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'region');
|
||||
this.setAttribute('aria-label', this.localize.term('carousel'));
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach(entry => {
|
||||
// Store all the entries in a map to be processed when scrolling ends
|
||||
this.intersectionObserverEntries.set(entry.target, entry);
|
||||
|
||||
const slide = entry.target;
|
||||
slide.toggleAttribute('inert', !entry.isIntersecting);
|
||||
slide.classList.toggle('--in-view', entry.isIntersecting);
|
||||
slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true');
|
||||
});
|
||||
},
|
||||
{
|
||||
root: this,
|
||||
threshold: 0.6
|
||||
}
|
||||
);
|
||||
this.intersectionObserver = intersectionObserver;
|
||||
|
||||
// Store the initial state of each slide
|
||||
intersectionObserver.takeRecords().forEach(entry => {
|
||||
this.intersectionObserverEntries.set(entry.target, entry);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.intersectionObserver.disconnect();
|
||||
this.mutationObserver.disconnect();
|
||||
}
|
||||
|
||||
protected firstUpdated(): void {
|
||||
this.initializeSlides();
|
||||
this.mutationObserver = new MutationObserver(this.handleSlotChange);
|
||||
this.mutationObserver.observe(this, { childList: true, subtree: false });
|
||||
}
|
||||
|
||||
private getPageCount() {
|
||||
return Math.ceil(this.getSlides().length / this.slidesPerPage);
|
||||
}
|
||||
|
||||
private getCurrentPage() {
|
||||
return Math.floor(this.activeSlide / this.slidesPerPage);
|
||||
}
|
||||
|
||||
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
|
||||
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
||||
const target = event.target as HTMLElement;
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
const isFocusInPagination = target.closest('[part~="pagination-item"]') !== null;
|
||||
const isNext =
|
||||
event.key === 'ArrowDown' || (!isRtl && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft');
|
||||
const isPrevious =
|
||||
event.key === 'ArrowUp' || (!isRtl && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight');
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (isPrevious) {
|
||||
this.previous();
|
||||
}
|
||||
|
||||
if (isNext) {
|
||||
this.next();
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
this.goToSlide(0);
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
this.goToSlide(this.getSlides().length - 1);
|
||||
}
|
||||
|
||||
if (isFocusInPagination) {
|
||||
this.updateComplete.then(() => {
|
||||
const activePaginationItem = this.shadowRoot?.querySelector<HTMLButtonElement>(
|
||||
'[part~="pagination-item--active"]'
|
||||
);
|
||||
|
||||
if (activePaginationItem) {
|
||||
activePaginationItem.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleScrollEnd() {
|
||||
const slides = this.getSlides();
|
||||
const entries = [...this.intersectionObserverEntries.values()];
|
||||
|
||||
const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting);
|
||||
|
||||
if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) {
|
||||
const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone'));
|
||||
|
||||
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
|
||||
this.goToSlide(clonePosition, 'auto');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate the first intersecting slide
|
||||
if (firstIntersecting) {
|
||||
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlotChange = (mutations: MutationRecord[]) => {
|
||||
const needsInitialization = mutations.some(mutation =>
|
||||
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
||||
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
|
||||
)
|
||||
);
|
||||
|
||||
// Reinitialize the carousel if a carousel item has been added or removed
|
||||
if (needsInitialization) {
|
||||
this.initializeSlides();
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
@watch('loop', { waitUntilFirstUpdate: true })
|
||||
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
||||
initializeSlides() {
|
||||
const slides = this.getSlides();
|
||||
const intersectionObserver = this.intersectionObserver;
|
||||
|
||||
this.intersectionObserverEntries.clear();
|
||||
|
||||
// Removes all the cloned elements from the carousel
|
||||
this.getSlides({ excludeClones: false }).forEach((slide, index) => {
|
||||
intersectionObserver.unobserve(slide);
|
||||
|
||||
slide.classList.remove('--in-view');
|
||||
slide.classList.remove('--is-active');
|
||||
slide.setAttribute('aria-label', this.localize.term('slideNum', index + 1));
|
||||
|
||||
if (slide.hasAttribute('data-clone')) {
|
||||
slide.remove();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.loop) {
|
||||
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
|
||||
const slidesPerPage = this.slidesPerPage;
|
||||
const lastSlides = slides.slice(-slidesPerPage);
|
||||
const firstSlides = slides.slice(0, slidesPerPage);
|
||||
|
||||
lastSlides.reverse().forEach((slide, i) => {
|
||||
const clone = slide.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute('data-clone', String(slides.length - i - 1));
|
||||
this.prepend(clone);
|
||||
});
|
||||
|
||||
firstSlides.forEach((slide, i) => {
|
||||
const clone = slide.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute('data-clone', String(i));
|
||||
this.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||
intersectionObserver.observe(slide);
|
||||
});
|
||||
|
||||
// Because the DOM may be changed, restore the scroll position to the active slide
|
||||
this.goToSlide(this.activeSlide, 'auto');
|
||||
}
|
||||
|
||||
@watch('activeSlide')
|
||||
handelSlideChange() {
|
||||
const slides = this.getSlides();
|
||||
slides.forEach((slide, i) => {
|
||||
slide.classList.toggle('--is-active', i === this.activeSlide);
|
||||
});
|
||||
|
||||
// Do not emit an event on first render
|
||||
if (this.hasUpdated) {
|
||||
this.emit('sl-slide-change', {
|
||||
detail: {
|
||||
index: this.activeSlide,
|
||||
slide: slides[this.activeSlide]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@watch('slidesPerMove')
|
||||
handleSlidesPerMoveChange() {
|
||||
const slides = this.getSlides({ excludeClones: false });
|
||||
|
||||
const slidesPerMove = this.slidesPerMove;
|
||||
slides.forEach((slide, i) => {
|
||||
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
|
||||
if (shouldSnap) {
|
||||
slide.style.removeProperty('scroll-snap-align');
|
||||
} else {
|
||||
slide.style.setProperty('scroll-snap-align', 'none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@watch('autoplay')
|
||||
handleAutoplayChange() {
|
||||
this.autoplayController.stop();
|
||||
if (this.autoplay) {
|
||||
this.autoplayController.start(this.autoplayInterval);
|
||||
}
|
||||
}
|
||||
|
||||
@watch('mouseDragging')
|
||||
handleMouseDraggingChange() {
|
||||
this.scrollController.mouseDragging = this.mouseDragging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the carousel backward by `slides-per-move` slides.
|
||||
*
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
previous(behavior: ScrollBehavior = 'smooth') {
|
||||
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the carousel forward by `slides-per-move` slides.
|
||||
*
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
next(behavior: ScrollBehavior = 'smooth') {
|
||||
this.goToSlide(this.activeSlide + this.slidesPerMove, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the carousel to the slide specified by `index`.
|
||||
*
|
||||
* @param index - The slide index.
|
||||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
|
||||
const { slidesPerPage, loop, scrollContainer } = this;
|
||||
|
||||
const slides = this.getSlides();
|
||||
const slidesWithClones = this.getSlides({ excludeClones: false });
|
||||
|
||||
// Sets the next index without taking into account clones, if any.
|
||||
const newActiveSlide = (index + slides.length) % slides.length;
|
||||
this.activeSlide = newActiveSlide;
|
||||
|
||||
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
|
||||
// to normalize the starting index in order to ignore the first nth clones.
|
||||
const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
|
||||
const nextSlide = slidesWithClones[nextSlideIndex];
|
||||
|
||||
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const nextSlideRect = nextSlide.getBoundingClientRect();
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft,
|
||||
top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop,
|
||||
behavior: prefersReducedMotion() ? 'auto' : behavior
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { scrollController, slidesPerPage } = this;
|
||||
const pagesCount = this.getPageCount();
|
||||
const currentPage = this.getCurrentPage();
|
||||
const prevEnabled = this.loop || currentPage > 0;
|
||||
const nextEnabled = this.loop || currentPage < pagesCount - 1;
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
|
||||
return html`
|
||||
<div part="base" class="carousel">
|
||||
<div
|
||||
id="scroll-container"
|
||||
part="scroll-container"
|
||||
class="${classMap({
|
||||
carousel__slides: true,
|
||||
'carousel__slides--horizontal': this.orientation === 'horizontal',
|
||||
'carousel__slides--vertical': this.orientation === 'vertical'
|
||||
})}"
|
||||
style="--slides-per-page: ${this.slidesPerPage};"
|
||||
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
|
||||
aria-atomic="true"
|
||||
tabindex="0"
|
||||
@keydown=${this.handleKeyDown}
|
||||
@scrollend=${this.handleScrollEnd}
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
${this.navigation
|
||||
? html`
|
||||
<div part="navigation" class="carousel__navigation">
|
||||
<button
|
||||
part="navigation-button navigation-button--previous"
|
||||
class="${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--previous': true,
|
||||
'carousel__navigation-button--disabled': !prevEnabled
|
||||
})}"
|
||||
aria-label="${this.localize.term('previousSlide')}"
|
||||
aria-controls="scroll-container"
|
||||
aria-disabled="${prevEnabled ? 'false' : 'true'}"
|
||||
@click=${prevEnabled ? () => this.previous() : null}
|
||||
>
|
||||
<slot name="previous-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<button
|
||||
part="navigation-button navigation-button--next"
|
||||
class=${classMap({
|
||||
'carousel__navigation-button': true,
|
||||
'carousel__navigation-button--next': true,
|
||||
'carousel__navigation-button--disabled': !nextEnabled
|
||||
})}
|
||||
aria-label="${this.localize.term('nextSlide')}"
|
||||
aria-controls="scroll-container"
|
||||
aria-disabled="${nextEnabled ? 'false' : 'true'}"
|
||||
@click=${nextEnabled ? () => this.next() : null}
|
||||
>
|
||||
<slot name="next-icon">
|
||||
<sl-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${this.pagination
|
||||
? html`
|
||||
<div part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
|
||||
${map(range(pagesCount), index => {
|
||||
const isActive = index === currentPage;
|
||||
return html`
|
||||
<button
|
||||
part="pagination-item ${isActive ? 'pagination-item--active' : ''}"
|
||||
class="${classMap({
|
||||
'carousel__pagination-item': true,
|
||||
'carousel__pagination-item--active': isActive
|
||||
})}"
|
||||
role="tab"
|
||||
aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
||||
tabindex=${isActive ? '0' : '-1'}
|
||||
@click=${() => this.goToSlide(index * slidesPerPage)}
|
||||
@keydown=${this.handleKeyDown}
|
||||
></button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-carousel': SlCarousel;
|
||||
}
|
||||
}
|
||||
import SlCarousel from './carousel.component.js';
|
||||
export * from './carousel.component.js';
|
||||
export default SlCarousel;
|
||||
SlCarousel.define('sl-carousel');
|
||||
|
||||
251
src/components/checkbox/checkbox.component.ts
Normal file
251
src/components/checkbox/checkbox.component.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './checkbox.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Checkboxes allow the user to toggle an option on or off.
|
||||
* @documentation https://shoelace.style/components/checkbox
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The checkbox's label.
|
||||
*
|
||||
* @event sl-blur - Emitted when the checkbox loses focus.
|
||||
* @event sl-change - Emitted when the checked state changes.
|
||||
* @event sl-focus - Emitted when the checkbox gains focus.
|
||||
* @event sl-input - Emitted when the checkbox receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart control - The square container that wraps the checkbox's checked state.
|
||||
* @csspart control--checked - Matches the control part when the checkbox is checked.
|
||||
* @csspart control--indeterminate - Matches the control part when the checkbox is indeterminate.
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart indeterminate-icon - The indeterminate icon, an `<sl-icon>` element.
|
||||
* @csspart label - The container that wraps the checkbox's label.
|
||||
*/
|
||||
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined),
|
||||
defaultValue: (control: SlCheckbox) => control.defaultChecked,
|
||||
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
|
||||
});
|
||||
|
||||
@query('input[type="checkbox"]') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The name of the checkbox, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the checkbox, submitted as a name/value pair with form data. */
|
||||
@property() value: string;
|
||||
|
||||
/** The checkbox's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Disables the checkbox. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Draws the checkbox in a checked state. */
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/**
|
||||
* Draws the checkbox in an indeterminate state. This is usually applied to checkboxes that represents a "select
|
||||
* all/none" behavior when associated checkboxes have a mix of checked and unchecked states.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) indeterminate = false;
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue('checked') defaultChecked = false;
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Makes the checkbox a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.checked = !this.checked;
|
||||
this.indeterminate = false;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch(['checked', 'indeterminate'], { waitUntilFirstUpdate: true })
|
||||
handleStateChange() {
|
||||
this.input.checked = this.checked; // force a sync update
|
||||
this.input.indeterminate = this.indeterminate; // force a sync update
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Simulates a click on the checkbox. */
|
||||
click() {
|
||||
this.input.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the checkbox. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the checkbox. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom validation message. The value provided will be shown to the user when the form is submitted. To clear
|
||||
* the custom validation message, call this method with an empty string.
|
||||
*/
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
//
|
||||
// NOTE: we use a <div> around the label slot because of this Chrome bug.
|
||||
//
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1413733
|
||||
//
|
||||
return html`
|
||||
<label
|
||||
part="base"
|
||||
class=${classMap({
|
||||
checkbox: true,
|
||||
'checkbox--checked': this.checked,
|
||||
'checkbox--disabled': this.disabled,
|
||||
'checkbox--focused': this.hasFocus,
|
||||
'checkbox--indeterminate': this.indeterminate,
|
||||
'checkbox--small': this.size === 'small',
|
||||
'checkbox--medium': this.size === 'medium',
|
||||
'checkbox--large': this.size === 'large'
|
||||
})}
|
||||
>
|
||||
<input
|
||||
class="checkbox__input"
|
||||
type="checkbox"
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${this.name}
|
||||
value=${ifDefined(this.value)}
|
||||
.indeterminate=${live(this.indeterminate)}
|
||||
.checked=${live(this.checked)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
/>
|
||||
|
||||
<span
|
||||
part="control${this.checked ? ' control--checked' : ''}${this.indeterminate ? ' control--indeterminate' : ''}"
|
||||
class="checkbox__control"
|
||||
>
|
||||
${this.checked
|
||||
? html`
|
||||
<sl-icon part="checked-icon" class="checkbox__checked-icon" library="system" name="check"></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
${!this.checked && this.indeterminate
|
||||
? html`
|
||||
<sl-icon
|
||||
part="indeterminate-icon"
|
||||
class="checkbox__indeterminate-icon"
|
||||
library="system"
|
||||
name="indeterminate"
|
||||
></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<div part="label" class="checkbox__label">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-checkbox': SlCheckbox;
|
||||
}
|
||||
}
|
||||
@@ -1,251 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './checkbox.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Checkboxes allow the user to toggle an option on or off.
|
||||
* @documentation https://shoelace.style/components/checkbox
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The checkbox's label.
|
||||
*
|
||||
* @event sl-blur - Emitted when the checkbox loses focus.
|
||||
* @event sl-change - Emitted when the checked state changes.
|
||||
* @event sl-focus - Emitted when the checkbox gains focus.
|
||||
* @event sl-input - Emitted when the checkbox receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart control - The square container that wraps the checkbox's checked state.
|
||||
* @csspart control--checked - Matches the control part when the checkbox is checked.
|
||||
* @csspart control--indeterminate - Matches the control part when the checkbox is indeterminate.
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart indeterminate-icon - The indeterminate icon, an `<sl-icon>` element.
|
||||
* @csspart label - The container that wraps the checkbox's label.
|
||||
*/
|
||||
@customElement('sl-checkbox')
|
||||
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined),
|
||||
defaultValue: (control: SlCheckbox) => control.defaultChecked,
|
||||
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
|
||||
});
|
||||
|
||||
@query('input[type="checkbox"]') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
/** The name of the checkbox, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the checkbox, submitted as a name/value pair with form data. */
|
||||
@property() value: string;
|
||||
|
||||
/** The checkbox's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Disables the checkbox. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Draws the checkbox in a checked state. */
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/**
|
||||
* Draws the checkbox in an indeterminate state. This is usually applied to checkboxes that represents a "select
|
||||
* all/none" behavior when associated checkboxes have a mix of checked and unchecked states.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) indeterminate = false;
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue('checked') defaultChecked = false;
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Makes the checkbox a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.checked = !this.checked;
|
||||
this.indeterminate = false;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch(['checked', 'indeterminate'], { waitUntilFirstUpdate: true })
|
||||
handleStateChange() {
|
||||
this.input.checked = this.checked; // force a sync update
|
||||
this.input.indeterminate = this.indeterminate; // force a sync update
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Simulates a click on the checkbox. */
|
||||
click() {
|
||||
this.input.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the checkbox. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the checkbox. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom validation message. The value provided will be shown to the user when the form is submitted. To clear
|
||||
* the custom validation message, call this method with an empty string.
|
||||
*/
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
//
|
||||
// NOTE: we use a <div> around the label slot because of this Chrome bug.
|
||||
//
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1413733
|
||||
//
|
||||
return html`
|
||||
<label
|
||||
part="base"
|
||||
class=${classMap({
|
||||
checkbox: true,
|
||||
'checkbox--checked': this.checked,
|
||||
'checkbox--disabled': this.disabled,
|
||||
'checkbox--focused': this.hasFocus,
|
||||
'checkbox--indeterminate': this.indeterminate,
|
||||
'checkbox--small': this.size === 'small',
|
||||
'checkbox--medium': this.size === 'medium',
|
||||
'checkbox--large': this.size === 'large'
|
||||
})}
|
||||
>
|
||||
<input
|
||||
class="checkbox__input"
|
||||
type="checkbox"
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${this.name}
|
||||
value=${ifDefined(this.value)}
|
||||
.indeterminate=${live(this.indeterminate)}
|
||||
.checked=${live(this.checked)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
/>
|
||||
|
||||
<span
|
||||
part="control${this.checked ? ' control--checked' : ''}${this.indeterminate ? ' control--indeterminate' : ''}"
|
||||
class="checkbox__control"
|
||||
>
|
||||
${this.checked
|
||||
? html`
|
||||
<sl-icon part="checked-icon" class="checkbox__checked-icon" library="system" name="check"></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
${!this.checked && this.indeterminate
|
||||
? html`
|
||||
<sl-icon
|
||||
part="indeterminate-icon"
|
||||
class="checkbox__indeterminate-icon"
|
||||
library="system"
|
||||
name="indeterminate"
|
||||
></sl-icon>
|
||||
`
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<div part="label" class="checkbox__label">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-checkbox': SlCheckbox;
|
||||
}
|
||||
}
|
||||
import SlCheckbox from './checkbox.component.js';
|
||||
export * from './checkbox.component.js';
|
||||
export default SlCheckbox;
|
||||
SlCheckbox.define('sl-checkbox');
|
||||
|
||||
1073
src/components/color-picker/color-picker.component.ts
Normal file
1073
src/components/color-picker/color-picker.component.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
252
src/components/details/details.component.ts
Normal file
252
src/components/details/details.component.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './details.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Details show a brief summary and expand to show additional content.
|
||||
* @documentation https://shoelace.style/components/details
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The details' main content.
|
||||
* @slot summary - The details' summary. Alternatively, you can use the `summary` attribute.
|
||||
* @slot expand-icon - Optional expand icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot collapse-icon - Optional collapse icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @event sl-show - Emitted when the details opens.
|
||||
* @event sl-after-show - Emitted after the details opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the details closes.
|
||||
* @event sl-after-hide - Emitted after the details closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart header - The header that wraps both the summary and the expand/collapse icon.
|
||||
* @csspart summary - The container that wraps the summary.
|
||||
* @csspart summary-icon - The container that wraps the expand/collapse icons.
|
||||
* @csspart 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.
|
||||
*/
|
||||
export default class SlDetails extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
static dependencies = {
|
||||
'sl-icon': SlIcon
|
||||
};
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.details') details: HTMLDetailsElement;
|
||||
@query('.details__header') header: HTMLElement;
|
||||
@query('.details__body') body: HTMLElement;
|
||||
@query('.details__expand-icon-slot') expandIconSlot: HTMLSlotElement;
|
||||
|
||||
detailsObserver: MutationObserver;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the details is open. You can toggle this attribute to show and hide the details, or you
|
||||
* can use the `show()` and `hide()` methods and this attribute will reflect the details' open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/** The summary to show in the header. If you need to display HTML, use the `summary` slot instead. */
|
||||
@property() summary: string;
|
||||
|
||||
/** Disables the details so it can't be toggled. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.body.style.height = this.open ? 'auto' : '0';
|
||||
if (this.open) {
|
||||
this.details.open = true;
|
||||
}
|
||||
|
||||
this.detailsObserver = new MutationObserver(changes => {
|
||||
for (const change of changes) {
|
||||
if (change.type === 'attributes' && change.attributeName === 'open') {
|
||||
if (this.details.open) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
this.detailsObserver.observe(this.details, { attributes: true });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.detailsObserver.disconnect();
|
||||
}
|
||||
|
||||
private handleSummaryClick(ev: MouseEvent) {
|
||||
ev.preventDefault();
|
||||
if (!this.disabled) {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
|
||||
this.header.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleSummaryKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
this.details.open = true;
|
||||
// Show
|
||||
const slShow = this.emit('sl-show', { cancelable: true });
|
||||
if (slShow.defaultPrevented) {
|
||||
this.open = false;
|
||||
this.details.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await stopAnimations(this.body);
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'details.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
|
||||
this.body.style.height = 'auto';
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
const slHide = this.emit('sl-hide', { cancelable: true });
|
||||
if (slHide.defaultPrevented) {
|
||||
this.details.open = true;
|
||||
this.open = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await stopAnimations(this.body);
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'details.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
|
||||
this.body.style.height = 'auto';
|
||||
|
||||
this.details.open = false;
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the details. */
|
||||
async show() {
|
||||
if (this.open || this.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the details */
|
||||
async hide() {
|
||||
if (!this.open || this.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
return html`
|
||||
<details
|
||||
part="base"
|
||||
class=${classMap({
|
||||
details: true,
|
||||
'details--open': this.open,
|
||||
'details--disabled': this.disabled,
|
||||
'details--rtl': isRtl
|
||||
})}
|
||||
>
|
||||
<summary
|
||||
part="header"
|
||||
id="header"
|
||||
class="details__header"
|
||||
role="button"
|
||||
aria-expanded=${this.open ? 'true' : 'false'}
|
||||
aria-controls="content"
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@click=${this.handleSummaryClick}
|
||||
@keydown=${this.handleSummaryKeyDown}
|
||||
>
|
||||
<slot name="summary" part="summary" class="details__summary">${this.summary}</slot>
|
||||
|
||||
<span part="summary-icon" class="details__summary-icon">
|
||||
<slot name="expand-icon">
|
||||
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
|
||||
</slot>
|
||||
<slot name="collapse-icon">
|
||||
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
|
||||
</slot>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="details__body" role="region" aria-labelledby="header">
|
||||
<slot part="content" id="content" class="details__content"></slot>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export default css`
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details__header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.details__header:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -31,20 +31,19 @@ describe('<sl-details>', () => {
|
||||
`);
|
||||
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
|
||||
|
||||
expect(body.hidden).to.be.false;
|
||||
expect(parseInt(getComputedStyle(body).height)).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it('should not be visible without the open attribute', async () => {
|
||||
const el = await fixture<SlDetails>(html`
|
||||
<sl-details>
|
||||
<sl-details summary="click me">
|
||||
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<HTMLElement>('.details__body')!;
|
||||
|
||||
expect(body.hidden).to.be.true;
|
||||
expect(parseInt(getComputedStyle(body).height)).to.equal(0);
|
||||
});
|
||||
|
||||
it('should emit sl-show and sl-after-show when calling show()', async () => {
|
||||
@@ -55,7 +54,6 @@ describe('<sl-details>', () => {
|
||||
consequat.
|
||||
</sl-details>
|
||||
`);
|
||||
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
|
||||
const showHandler = sinon.spy();
|
||||
const afterShowHandler = sinon.spy();
|
||||
|
||||
@@ -68,7 +66,6 @@ describe('<sl-details>', () => {
|
||||
|
||||
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 () => {
|
||||
@@ -79,7 +76,6 @@ describe('<sl-details>', () => {
|
||||
consequat.
|
||||
</sl-details>
|
||||
`);
|
||||
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
|
||||
const hideHandler = sinon.spy();
|
||||
const afterHideHandler = sinon.spy();
|
||||
|
||||
@@ -92,7 +88,6 @@ describe('<sl-details>', () => {
|
||||
|
||||
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 () => {
|
||||
@@ -127,7 +122,6 @@ describe('<sl-details>', () => {
|
||||
consequat.
|
||||
</sl-details>
|
||||
`);
|
||||
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
|
||||
const hideHandler = sinon.spy();
|
||||
const afterHideHandler = sinon.spy();
|
||||
|
||||
@@ -140,7 +134,6 @@ describe('<sl-details>', () => {
|
||||
|
||||
expect(hideHandler).to.have.been.calledOnce;
|
||||
expect(afterHideHandler).to.have.been.calledOnce;
|
||||
expect(body.hidden).to.be.true;
|
||||
});
|
||||
|
||||
it('should not open when preventing sl-show', async () => {
|
||||
|
||||
@@ -1,225 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './details.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Details show a brief summary and expand to show additional content.
|
||||
* @documentation https://shoelace.style/components/details
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The details' main content.
|
||||
* @slot summary - The details' summary. Alternatively, you can use the `summary` attribute.
|
||||
* @slot expand-icon - Optional expand icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
* @slot collapse-icon - Optional collapse icon to use instead of the default. Works best with `<sl-icon>`.
|
||||
*
|
||||
* @event sl-show - Emitted when the details opens.
|
||||
* @event sl-after-show - Emitted after the details opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the details closes.
|
||||
* @event sl-after-hide - Emitted after the details closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart header - The header that wraps both the summary and the expand/collapse icon.
|
||||
* @csspart summary - The container that wraps the summary.
|
||||
* @csspart summary-icon - The container that wraps the expand/collapse icons.
|
||||
* @csspart 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 ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.details') details: HTMLElement;
|
||||
@query('.details__header') header: HTMLElement;
|
||||
@query('.details__body') body: HTMLElement;
|
||||
@query('.details__expand-icon-slot') expandIconSlot: HTMLSlotElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the details is open. You can toggle this attribute to show and hide the details, or you
|
||||
* can use the `show()` and `hide()` methods and this attribute will reflect the details' open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/** The summary to show in the header. If you need to display HTML, use the `summary` slot instead. */
|
||||
@property() summary: string;
|
||||
|
||||
/** Disables the details so it can't be toggled. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.body.hidden = !this.open;
|
||||
this.body.style.height = this.open ? 'auto' : '0';
|
||||
}
|
||||
|
||||
private handleSummaryClick() {
|
||||
if (!this.disabled) {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
|
||||
this.header.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleSummaryKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
const slShow = this.emit('sl-show', { cancelable: true });
|
||||
if (slShow.defaultPrevented) {
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await stopAnimations(this.body);
|
||||
this.body.hidden = false;
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'details.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
|
||||
this.body.style.height = 'auto';
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
const slHide = this.emit('sl-hide', { cancelable: true });
|
||||
if (slHide.defaultPrevented) {
|
||||
this.open = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await stopAnimations(this.body);
|
||||
|
||||
const { keyframes, options } = getAnimation(this, 'details.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.body, shimKeyframesHeightAuto(keyframes, this.body.scrollHeight), options);
|
||||
this.body.hidden = true;
|
||||
this.body.style.height = 'auto';
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the details. */
|
||||
async show() {
|
||||
if (this.open || this.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the details */
|
||||
async hide() {
|
||||
if (!this.open || this.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
details: true,
|
||||
'details--open': this.open,
|
||||
'details--disabled': this.disabled,
|
||||
'details--rtl': isRtl
|
||||
})}
|
||||
>
|
||||
<div
|
||||
part="header"
|
||||
id="header"
|
||||
class="details__header"
|
||||
role="button"
|
||||
aria-expanded=${this.open ? 'true' : 'false'}
|
||||
aria-controls="content"
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@click=${this.handleSummaryClick}
|
||||
@keydown=${this.handleSummaryKeyDown}
|
||||
>
|
||||
<slot name="summary" part="summary" class="details__summary">${this.summary}</slot>
|
||||
|
||||
<span part="summary-icon" class="details__summary-icon">
|
||||
<slot name="expand-icon">
|
||||
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
|
||||
</slot>
|
||||
<slot name="collapse-icon">
|
||||
<sl-icon library="system" name=${isRtl ? 'chevron-left' : 'chevron-right'}></sl-icon>
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="details__body" role="region" aria-labelledby="header">
|
||||
<slot part="content" id="content" class="details__content"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
import SlDetails from './details.component.js';
|
||||
export * from './details.component.js';
|
||||
export default SlDetails;
|
||||
SlDetails.define('sl-details');
|
||||
|
||||
347
src/components/dialog/dialog.component.ts
Normal file
347
src/components/dialog/dialog.component.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIconButton from '../icon-button/icon-button.component.js';
|
||||
import styles from './dialog.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Dialogs, sometimes called "modals", appear above the page and require the user's immediate attention.
|
||||
* @documentation https://shoelace.style/components/dialog
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The dialog's main content.
|
||||
* @slot label - The dialog's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
|
||||
*
|
||||
* @event sl-show - Emitted when the dialog opens.
|
||||
* @event sl-after-show - Emitted after the dialog opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the dialog closes.
|
||||
* @event sl-after-hide - Emitted after the dialog closes and all animations are complete.
|
||||
* @event sl-initial-focus - Emitted when the dialog opens and is ready to receive focus. Calling
|
||||
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
|
||||
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
|
||||
* close the dialog by clicking the close button, clicking the overlay, or pressing escape. Calling
|
||||
* `event.preventDefault()` will keep the dialog open. Avoid using this unless closing the dialog will result in
|
||||
* destructive behavior such as data loss.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart overlay - The overlay that covers the screen behind the dialog.
|
||||
* @csspart panel - The dialog's panel (where the dialog and its content are rendered).
|
||||
* @csspart header - The dialog's header. This element wraps the title and header actions.
|
||||
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @csspart title - The dialog's title.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
* @csspart body - The dialog's body.
|
||||
* @csspart footer - The dialog's footer.
|
||||
*
|
||||
* @cssproperty --width - The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
|
||||
* @cssproperty --header-spacing - The amount of padding to use for the header.
|
||||
* @cssproperty --body-spacing - The amount of padding to use for the body.
|
||||
* @cssproperty --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.denyClose - The animation to use when a request to close the dialog is denied.
|
||||
* @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.
|
||||
*/
|
||||
export default class SlDialog extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = {
|
||||
'sl-icon-button': SlIconButton
|
||||
};
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private modal = new Modal(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
|
||||
@query('.dialog') dialog: HTMLElement;
|
||||
@query('.dialog__panel') panel: HTMLElement;
|
||||
@query('.dialog__overlay') overlay: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The dialog's label as displayed in the header. You should always include a relevant label even when using
|
||||
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
|
||||
*/
|
||||
@property({ reflect: true }) label = '';
|
||||
|
||||
/**
|
||||
* Disables the header. This will also remove the default close button, so please ensure you provide an easy,
|
||||
* accessible way for users to dismiss the dialog.
|
||||
*/
|
||||
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.dialog.hidden = !this.open;
|
||||
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
const slRequestClose = this.emit('sl-request-close', {
|
||||
cancelable: true,
|
||||
detail: { source }
|
||||
});
|
||||
|
||||
if (slRequestClose.defaultPrevented) {
|
||||
const animation = getAnimation(this, 'dialog.denyClose', { dir: this.localize.dir() });
|
||||
animateTo(this.panel, animation.keyframes, animation.options);
|
||||
return;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && this.modal.isActive() && this.open) {
|
||||
event.stopPropagation();
|
||||
this.requestClose('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
this.originalTrigger = document.activeElement as HTMLElement;
|
||||
this.modal.activate();
|
||||
|
||||
lockBodyScrolling(this);
|
||||
|
||||
// When the dialog is shown, Safari will attempt to set focus on whatever element has autofocus. This can cause
|
||||
// the dialogs's animation to jitter (if it starts offscreen), so we'll temporarily remove the attribute, call
|
||||
// `focus({ preventScroll: true })` ourselves, and add the attribute back afterwards.
|
||||
//
|
||||
// Related: https://github.com/shoelace-style/shoelace/issues/693
|
||||
//
|
||||
const autoFocusTarget = this.querySelector('[autofocus]');
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.removeAttribute('autofocus');
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
|
||||
this.dialog.hidden = false;
|
||||
|
||||
// Set initial focus
|
||||
requestAnimationFrame(() => {
|
||||
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
|
||||
|
||||
if (!slInitialFocus.defaultPrevented) {
|
||||
// Set focus to the autofocus target and restore the attribute
|
||||
if (autoFocusTarget) {
|
||||
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
|
||||
} else {
|
||||
this.panel.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the autofocus attribute
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.setAttribute('autofocus', '');
|
||||
}
|
||||
});
|
||||
|
||||
const panelAnimation = getAnimation(this, 'dialog.show', { dir: this.localize.dir() });
|
||||
const overlayAnimation = getAnimation(this, 'dialog.overlay.show', { dir: this.localize.dir() });
|
||||
await Promise.all([
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
|
||||
]);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
this.modal.deactivate();
|
||||
|
||||
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
|
||||
const panelAnimation = getAnimation(this, 'dialog.hide', { dir: this.localize.dir() });
|
||||
const overlayAnimation = getAnimation(this, 'dialog.overlay.hide', { dir: this.localize.dir() });
|
||||
|
||||
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
|
||||
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
|
||||
// unexpectedly. We'll unhide them after all animations have completed.
|
||||
await Promise.all([
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
|
||||
this.overlay.hidden = true;
|
||||
}),
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
|
||||
this.panel.hidden = true;
|
||||
})
|
||||
]);
|
||||
|
||||
this.dialog.hidden = true;
|
||||
|
||||
// Now that the dialog is hidden, restore the overlay and panel for next time
|
||||
this.overlay.hidden = false;
|
||||
this.panel.hidden = false;
|
||||
|
||||
unlockBodyScrolling(this);
|
||||
|
||||
// Restore focus to the original trigger
|
||||
const trigger = this.originalTrigger;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
setTimeout(() => trigger.focus());
|
||||
}
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the dialog. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the dialog */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
dialog: true,
|
||||
'dialog--open': this.open,
|
||||
'dialog--has-footer': this.hasSlotController.test('footer')
|
||||
})}
|
||||
>
|
||||
<div part="overlay" class="dialog__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
|
||||
|
||||
<div
|
||||
part="panel"
|
||||
class="dialog__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
|
||||
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
|
||||
tabindex="-1"
|
||||
>
|
||||
${!this.noHeader
|
||||
? html`
|
||||
<header part="header" class="dialog__header">
|
||||
<h2 part="title" class="dialog__title" id="title">
|
||||
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
|
||||
</h2>
|
||||
<div part="header-actions" class="dialog__header-actions">
|
||||
<slot name="header-actions"></slot>
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="dialog__close"
|
||||
name="x-lg"
|
||||
label=${this.localize.term('close')}
|
||||
library="system"
|
||||
@click="${() => this.requestClose('close-button')}"
|
||||
></sl-icon-button>
|
||||
</div>
|
||||
</header>
|
||||
`
|
||||
: ''}
|
||||
${
|
||||
'' /* The tabindex="-1" is here because the body is technically scrollable if overflowing. However, if there's no focusable elements inside, you won't actually be able to scroll it via keyboard. */
|
||||
}
|
||||
<slot part="body" class="dialog__body" tabindex="-1"></slot>
|
||||
|
||||
<footer part="footer" class="dialog__footer">
|
||||
<slot name="footer"></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('dialog.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.8 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.denyClose', {
|
||||
keyframes: [{ scale: 1 }, { scale: 1.02 }, { scale: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../../../dist/shoelace.js';
|
||||
// cspell:dictionaries lorem-ipsum
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { aTimeout, elementUpdated, expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { LitElement } from 'lit';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type SlDialog from './dialog';
|
||||
@@ -146,4 +147,124 @@ describe('<sl-dialog>', () => {
|
||||
|
||||
expect(el.open).to.be.false;
|
||||
});
|
||||
|
||||
// https://github.com/shoelace-style/shoelace/issues/1382
|
||||
it('should properly cycle through tabbable elements when sl-dialog is used in a shadowRoot', async () => {
|
||||
class AContainer extends LitElement {
|
||||
get dialog() {
|
||||
return this.shadowRoot?.querySelector('sl-dialog');
|
||||
}
|
||||
|
||||
openDialog() {
|
||||
this.dialog?.show();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<h1>Dialog Example</h1>
|
||||
<sl-dialog label="Dialog" class="dialog-overview">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
<br />
|
||||
<label><input type="checkbox" />A</label>
|
||||
<label><input type="checkbox" />B</label>
|
||||
<button>Button</button>
|
||||
</sl-dialog>
|
||||
|
||||
<sl-button @click=${this.openDialog}>Open Dialog</sl-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.customElements.get('a-container')) {
|
||||
window.customElements.define('a-container', AContainer);
|
||||
}
|
||||
|
||||
const testCase = await fixture(html`
|
||||
<div>
|
||||
<a-container></a-container>
|
||||
|
||||
<p>
|
||||
Open the dialog, then use <kbd>Tab</kbd> to cycle through the inputs. Focus should be trapped, but it reaches
|
||||
things outside the dialog.
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const container = testCase.querySelector('a-container');
|
||||
|
||||
if (!container) {
|
||||
throw Error('Could not find <a-container> element.');
|
||||
}
|
||||
|
||||
await elementUpdated(container);
|
||||
const dialog = container.shadowRoot?.querySelector('sl-dialog');
|
||||
|
||||
if (!dialog) {
|
||||
throw Error('Could not find <sl-dialog> element.');
|
||||
}
|
||||
|
||||
const closeButton = dialog.shadowRoot?.querySelector('sl-icon-button');
|
||||
const checkbox1 = dialog.querySelector("input[type='checkbox']");
|
||||
const checkbox2 = dialog.querySelectorAll("input[type='checkbox']")[1];
|
||||
const button = dialog.querySelector('button');
|
||||
|
||||
// Opens modal.
|
||||
const openModalButton = container.shadowRoot?.querySelector('sl-button');
|
||||
|
||||
if (openModalButton) openModalButton.click();
|
||||
|
||||
// Test tab cycling
|
||||
await pressTab();
|
||||
|
||||
expect(container.shadowRoot?.activeElement).to.equal(dialog);
|
||||
expect(dialog.shadowRoot?.activeElement).to.equal(closeButton);
|
||||
|
||||
await pressTab();
|
||||
expect(container.shadowRoot?.activeElement).to.equal(checkbox1);
|
||||
|
||||
await pressTab();
|
||||
expect(container.shadowRoot?.activeElement).to.equal(checkbox2);
|
||||
|
||||
await pressTab();
|
||||
expect(container.shadowRoot?.activeElement).to.equal(button);
|
||||
|
||||
await pressTab();
|
||||
expect(dialog.shadowRoot?.activeElement).to.equal(closeButton);
|
||||
|
||||
await pressTab();
|
||||
expect(container.shadowRoot?.activeElement).to.equal(checkbox1);
|
||||
|
||||
// Test Shift+Tab cycling
|
||||
|
||||
// I found these timeouts were needed for WebKit locally.
|
||||
await aTimeout(10);
|
||||
await sendKeys({ down: 'Shift' });
|
||||
await aTimeout(10);
|
||||
|
||||
await pressTab();
|
||||
expect(dialog.shadowRoot?.activeElement).to.equal(closeButton);
|
||||
|
||||
await pressTab();
|
||||
expect(container.shadowRoot?.activeElement).to.equal(button);
|
||||
|
||||
await pressTab();
|
||||
expect(container.shadowRoot?.activeElement).to.equal(checkbox2);
|
||||
|
||||
await pressTab();
|
||||
expect(container.shadowRoot?.activeElement).to.equal(checkbox1);
|
||||
|
||||
await pressTab();
|
||||
expect(dialog.shadowRoot?.activeElement).to.equal(closeButton);
|
||||
|
||||
// End shift+tab cycling
|
||||
await sendKeys({ up: 'Shift' });
|
||||
});
|
||||
});
|
||||
|
||||
// We wait 50ms just to give the browser some time to figure out the current focus.
|
||||
// 50 was the magic number I found locally :shrug:
|
||||
async function pressTab() {
|
||||
await aTimeout(50);
|
||||
await sendKeys({ press: 'Tab' });
|
||||
await aTimeout(50);
|
||||
}
|
||||
|
||||
@@ -1,342 +1,4 @@
|
||||
import '../icon-button/icon-button.js';
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './dialog.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Dialogs, sometimes called "modals", appear above the page and require the user's immediate attention.
|
||||
* @documentation https://shoelace.style/components/dialog
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The dialog's main content.
|
||||
* @slot label - The dialog's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
|
||||
*
|
||||
* @event sl-show - Emitted when the dialog opens.
|
||||
* @event sl-after-show - Emitted after the dialog opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the dialog closes.
|
||||
* @event sl-after-hide - Emitted after the dialog closes and all animations are complete.
|
||||
* @event sl-initial-focus - Emitted when the dialog opens and is ready to receive focus. Calling
|
||||
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
|
||||
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
|
||||
* close the dialog by clicking the close button, clicking the overlay, or pressing escape. Calling
|
||||
* `event.preventDefault()` will keep the dialog open. Avoid using this unless closing the dialog will result in
|
||||
* destructive behavior such as data loss.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart overlay - The overlay that covers the screen behind the dialog.
|
||||
* @csspart panel - The dialog's panel (where the dialog and its content are rendered).
|
||||
* @csspart header - The dialog's header. This element wraps the title and header actions.
|
||||
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @csspart title - The dialog's title.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
* @csspart body - The dialog's body.
|
||||
* @csspart footer - The dialog's footer.
|
||||
*
|
||||
* @cssproperty --width - The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
|
||||
* @cssproperty --header-spacing - The amount of padding to use for the header.
|
||||
* @cssproperty --body-spacing - The amount of padding to use for the body.
|
||||
* @cssproperty --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.denyClose - The animation to use when a request to close the dialog is denied.
|
||||
* @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 ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private modal = new Modal(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
|
||||
@query('.dialog') dialog: HTMLElement;
|
||||
@query('.dialog__panel') panel: HTMLElement;
|
||||
@query('.dialog__overlay') overlay: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The dialog's label as displayed in the header. You should always include a relevant label even when using
|
||||
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
|
||||
*/
|
||||
@property({ reflect: true }) label = '';
|
||||
|
||||
/**
|
||||
* Disables the header. This will also remove the default close button, so please ensure you provide an easy,
|
||||
* accessible way for users to dismiss the dialog.
|
||||
*/
|
||||
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.dialog.hidden = !this.open;
|
||||
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
const slRequestClose = this.emit('sl-request-close', {
|
||||
cancelable: true,
|
||||
detail: { source }
|
||||
});
|
||||
|
||||
if (slRequestClose.defaultPrevented) {
|
||||
const animation = getAnimation(this, 'dialog.denyClose', { dir: this.localize.dir() });
|
||||
animateTo(this.panel, animation.keyframes, animation.options);
|
||||
return;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (this.open && event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.requestClose('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
this.originalTrigger = document.activeElement as HTMLElement;
|
||||
this.modal.activate();
|
||||
|
||||
lockBodyScrolling(this);
|
||||
|
||||
// When the dialog is shown, Safari will attempt to set focus on whatever element has autofocus. This can cause
|
||||
// the dialogs's animation to jitter (if it starts offscreen), so we'll temporarily remove the attribute, call
|
||||
// `focus({ preventScroll: true })` ourselves, and add the attribute back afterwards.
|
||||
//
|
||||
// Related: https://github.com/shoelace-style/shoelace/issues/693
|
||||
//
|
||||
const autoFocusTarget = this.querySelector('[autofocus]');
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.removeAttribute('autofocus');
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
|
||||
this.dialog.hidden = false;
|
||||
|
||||
// Set initial focus
|
||||
requestAnimationFrame(() => {
|
||||
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
|
||||
|
||||
if (!slInitialFocus.defaultPrevented) {
|
||||
// Set focus to the autofocus target and restore the attribute
|
||||
if (autoFocusTarget) {
|
||||
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
|
||||
} else {
|
||||
this.panel.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the autofocus attribute
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.setAttribute('autofocus', '');
|
||||
}
|
||||
});
|
||||
|
||||
const panelAnimation = getAnimation(this, 'dialog.show', { dir: this.localize.dir() });
|
||||
const overlayAnimation = getAnimation(this, 'dialog.overlay.show', { dir: this.localize.dir() });
|
||||
await Promise.all([
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
|
||||
]);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
this.modal.deactivate();
|
||||
|
||||
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
|
||||
const panelAnimation = getAnimation(this, 'dialog.hide', { dir: this.localize.dir() });
|
||||
const overlayAnimation = getAnimation(this, 'dialog.overlay.hide', { dir: this.localize.dir() });
|
||||
|
||||
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
|
||||
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
|
||||
// unexpectedly. We'll unhide them after all animations have completed.
|
||||
await Promise.all([
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
|
||||
this.overlay.hidden = true;
|
||||
}),
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
|
||||
this.panel.hidden = true;
|
||||
})
|
||||
]);
|
||||
|
||||
this.dialog.hidden = true;
|
||||
|
||||
// Now that the dialog is hidden, restore the overlay and panel for next time
|
||||
this.overlay.hidden = false;
|
||||
this.panel.hidden = false;
|
||||
|
||||
unlockBodyScrolling(this);
|
||||
|
||||
// Restore focus to the original trigger
|
||||
const trigger = this.originalTrigger;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
setTimeout(() => trigger.focus());
|
||||
}
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the dialog. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the dialog */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
dialog: true,
|
||||
'dialog--open': this.open,
|
||||
'dialog--has-footer': this.hasSlotController.test('footer')
|
||||
})}
|
||||
>
|
||||
<div part="overlay" class="dialog__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
|
||||
|
||||
<div
|
||||
part="panel"
|
||||
class="dialog__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
|
||||
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
|
||||
tabindex="0"
|
||||
>
|
||||
${!this.noHeader
|
||||
? html`
|
||||
<header part="header" class="dialog__header">
|
||||
<h2 part="title" class="dialog__title" id="title">
|
||||
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
|
||||
</h2>
|
||||
<div part="header-actions" class="dialog__header-actions">
|
||||
<slot name="header-actions"></slot>
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="dialog__close"
|
||||
name="x-lg"
|
||||
label=${this.localize.term('close')}
|
||||
library="system"
|
||||
@click="${() => this.requestClose('close-button')}"
|
||||
></sl-icon-button>
|
||||
</div>
|
||||
</header>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<slot part="body" class="dialog__body"></slot>
|
||||
|
||||
<footer part="footer" class="dialog__footer">
|
||||
<slot name="footer"></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('dialog.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.8 }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dialog.denyClose', {
|
||||
keyframes: [{ scale: 1 }, { scale: 1.02 }, { scale: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
import SlDialog from './dialog.component.js';
|
||||
export * from './dialog.component.js';
|
||||
export default SlDialog;
|
||||
SlDialog.define('sl-dialog');
|
||||
|
||||
38
src/components/divider/divider.component.ts
Normal file
38
src/components/divider/divider.component.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './divider.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Dividers are used to visually separate or group elements.
|
||||
* @documentation https://shoelace.style/components/divider
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @cssproperty --color - The color of the divider.
|
||||
* @cssproperty --width - The width of the divider.
|
||||
* @cssproperty --spacing - The spacing of the divider.
|
||||
*/
|
||||
export default class SlDivider extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** Draws the divider in a vertical orientation. */
|
||||
@property({ type: Boolean, reflect: true }) vertical = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'separator');
|
||||
}
|
||||
|
||||
@watch('vertical')
|
||||
handleVerticalChange() {
|
||||
this.setAttribute('aria-orientation', this.vertical ? 'vertical' : 'horizontal');
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-divider': SlDivider;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './divider.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Dividers are used to visually separate or group elements.
|
||||
* @documentation https://shoelace.style/components/divider
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @cssproperty --color - The color of the divider.
|
||||
* @cssproperty --width - The width of the divider.
|
||||
* @cssproperty --spacing - The spacing of the divider.
|
||||
*/
|
||||
@customElement('sl-divider')
|
||||
export default class SlDivider extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/** Draws the divider in a vertical orientation. */
|
||||
@property({ type: Boolean, reflect: true }) vertical = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'separator');
|
||||
}
|
||||
|
||||
@watch('vertical')
|
||||
handleVerticalChange() {
|
||||
this.setAttribute('aria-orientation', this.vertical ? 'vertical' : 'horizontal');
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-divider': SlDivider;
|
||||
}
|
||||
}
|
||||
import SlDivider from './divider.component.js';
|
||||
export * from './divider.component.js';
|
||||
export default SlDivider;
|
||||
SlDivider.define('sl-divider');
|
||||
|
||||
467
src/components/drawer/drawer.component.ts
Normal file
467
src/components/drawer/drawer.component.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { uppercaseFirstLetter } from '../../internal/string.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIconButton from '../icon-button/icon-button.component.js';
|
||||
import styles from './drawer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Drawers slide in from a container to expose additional options and information.
|
||||
* @documentation https://shoelace.style/components/drawer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The drawer's main content.
|
||||
* @slot label - The drawer's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
|
||||
*
|
||||
* @event sl-show - Emitted when the drawer opens.
|
||||
* @event sl-after-show - Emitted after the drawer opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the drawer closes.
|
||||
* @event sl-after-hide - Emitted after the drawer closes and all animations are complete.
|
||||
* @event sl-initial-focus - Emitted when the drawer opens and is ready to receive focus. Calling
|
||||
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
|
||||
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
|
||||
* close the drawer by clicking the close button, clicking the overlay, or pressing escape. Calling
|
||||
* `event.preventDefault()` will keep the drawer open. Avoid using this unless closing the drawer will result in
|
||||
* destructive behavior such as data loss.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart overlay - The overlay that covers the screen behind the drawer.
|
||||
* @csspart panel - The drawer's panel (where the drawer and its content are rendered).
|
||||
* @csspart header - The drawer's header. This element wraps the title and header actions.
|
||||
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @csspart title - The drawer's title.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
* @csspart body - The drawer's body.
|
||||
* @csspart footer - The drawer's footer.
|
||||
*
|
||||
* @cssproperty --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.
|
||||
* @cssproperty --header-spacing - The amount of padding to use for the header.
|
||||
* @cssproperty --body-spacing - The amount of padding to use for the body.
|
||||
* @cssproperty --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.denyClose - The animation to use when a request to close the drawer is denied.
|
||||
* @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.
|
||||
*/
|
||||
export default class SlDrawer extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon-button': SlIconButton };
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private modal = new Modal(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
|
||||
@query('.drawer') drawer: HTMLElement;
|
||||
@query('.drawer__panel') panel: HTMLElement;
|
||||
@query('.drawer__overlay') overlay: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The drawer's label as displayed in the header. You should always include a relevant label even when using
|
||||
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
|
||||
*/
|
||||
@property({ reflect: true }) label = '';
|
||||
|
||||
/** The direction from which the drawer will open. */
|
||||
@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
|
||||
* its parent element, set this attribute and add `position: relative` to the parent.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) contained = false;
|
||||
|
||||
/**
|
||||
* Removes the header. This will also remove the default close button, so please ensure you provide an easy,
|
||||
* accessible way for users to dismiss the drawer.
|
||||
*/
|
||||
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.drawer.hidden = !this.open;
|
||||
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
|
||||
if (!this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
const slRequestClose = this.emit('sl-request-close', {
|
||||
cancelable: true,
|
||||
detail: { source }
|
||||
});
|
||||
|
||||
if (slRequestClose.defaultPrevented) {
|
||||
const animation = getAnimation(this, 'drawer.denyClose', { dir: this.localize.dir() });
|
||||
animateTo(this.panel, animation.keyframes, animation.options);
|
||||
return;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
// Contained drawers aren't modal and don't response to the escape key
|
||||
if (this.contained) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && this.modal.isActive() && this.open) {
|
||||
event.stopImmediatePropagation();
|
||||
this.requestClose('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
this.originalTrigger = document.activeElement as HTMLElement;
|
||||
|
||||
// Lock body scrolling only if the drawer isn't contained
|
||||
if (!this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
|
||||
// When the drawer is shown, Safari will attempt to set focus on whatever element has autofocus. This causes the
|
||||
// drawer's animation to jitter, so we'll temporarily remove the attribute, call `focus({ preventScroll: true })`
|
||||
// ourselves, and add the attribute back afterwards.
|
||||
//
|
||||
// Related: https://github.com/shoelace-style/shoelace/issues/693
|
||||
//
|
||||
const autoFocusTarget = this.querySelector('[autofocus]');
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.removeAttribute('autofocus');
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
|
||||
this.drawer.hidden = false;
|
||||
|
||||
// Set initial focus
|
||||
requestAnimationFrame(() => {
|
||||
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
|
||||
|
||||
if (!slInitialFocus.defaultPrevented) {
|
||||
// Set focus to the autofocus target and restore the attribute
|
||||
if (autoFocusTarget) {
|
||||
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
|
||||
} else {
|
||||
this.panel.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the autofocus attribute
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.setAttribute('autofocus', '');
|
||||
}
|
||||
});
|
||||
|
||||
const panelAnimation = getAnimation(this, `drawer.show${uppercaseFirstLetter(this.placement)}`, {
|
||||
dir: this.localize.dir()
|
||||
});
|
||||
const overlayAnimation = getAnimation(this, 'drawer.overlay.show', { dir: this.localize.dir() });
|
||||
await Promise.all([
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
|
||||
]);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
if (!this.contained) {
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
|
||||
const panelAnimation = getAnimation(this, `drawer.hide${uppercaseFirstLetter(this.placement)}`, {
|
||||
dir: this.localize.dir()
|
||||
});
|
||||
const overlayAnimation = getAnimation(this, 'drawer.overlay.hide', { dir: this.localize.dir() });
|
||||
|
||||
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
|
||||
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
|
||||
// unexpectedly. We'll unhide them after all animations have completed.
|
||||
await Promise.all([
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
|
||||
this.overlay.hidden = true;
|
||||
}),
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
|
||||
this.panel.hidden = true;
|
||||
})
|
||||
]);
|
||||
|
||||
this.drawer.hidden = true;
|
||||
|
||||
// Now that the dialog is hidden, restore the overlay and panel for next time
|
||||
this.overlay.hidden = false;
|
||||
this.panel.hidden = false;
|
||||
|
||||
// Restore focus to the original trigger
|
||||
const trigger = this.originalTrigger;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
setTimeout(() => trigger.focus());
|
||||
}
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('contained', { waitUntilFirstUpdate: true })
|
||||
handleNoModalChange() {
|
||||
if (this.open && !this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
|
||||
if (this.open && this.contained) {
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the drawer. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the drawer */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
drawer: true,
|
||||
'drawer--open': this.open,
|
||||
'drawer--top': this.placement === 'top',
|
||||
'drawer--end': this.placement === 'end',
|
||||
'drawer--bottom': this.placement === 'bottom',
|
||||
'drawer--start': this.placement === 'start',
|
||||
'drawer--contained': this.contained,
|
||||
'drawer--fixed': !this.contained,
|
||||
'drawer--rtl': this.localize.dir() === 'rtl',
|
||||
'drawer--has-footer': this.hasSlotController.test('footer')
|
||||
})}
|
||||
>
|
||||
<div part="overlay" class="drawer__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
|
||||
|
||||
<div
|
||||
part="panel"
|
||||
class="drawer__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
|
||||
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
|
||||
tabindex="0"
|
||||
>
|
||||
${!this.noHeader
|
||||
? html`
|
||||
<header part="header" class="drawer__header">
|
||||
<h2 part="title" class="drawer__title" id="title">
|
||||
<!-- If there's no label, use an invisible character to prevent the header from collapsing -->
|
||||
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
|
||||
</h2>
|
||||
<div part="header-actions" class="drawer__header-actions">
|
||||
<slot name="header-actions"></slot>
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="drawer__close"
|
||||
name="x-lg"
|
||||
label=${this.localize.term('close')}
|
||||
library="system"
|
||||
@click=${() => this.requestClose('close-button')}
|
||||
></sl-icon-button>
|
||||
</div>
|
||||
</header>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<slot part="body" class="drawer__body"></slot>
|
||||
|
||||
<footer part="footer" class="drawer__footer">
|
||||
<slot name="footer"></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Top
|
||||
setDefaultAnimation('drawer.showTop', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '0 -100%' },
|
||||
{ opacity: 1, translate: '0 0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideTop', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0 0' },
|
||||
{ opacity: 0, translate: '0 -100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// End
|
||||
setDefaultAnimation('drawer.showEnd', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 0, translate: '-100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideEnd', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '100%' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '-100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Bottom
|
||||
setDefaultAnimation('drawer.showBottom', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '0 100%' },
|
||||
{ opacity: 1, translate: '0 0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideBottom', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0 0' },
|
||||
{ opacity: 0, translate: '0 100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Start
|
||||
setDefaultAnimation('drawer.showStart', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '-100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 0, translate: '100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideStart', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '-100%' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Deny close
|
||||
setDefaultAnimation('drawer.denyClose', {
|
||||
keyframes: [{ scale: 1 }, { scale: 1.01 }, { scale: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,462 +1,4 @@
|
||||
import '../icon-button/icon-button.js';
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
|
||||
import { uppercaseFirstLetter } from '../../internal/string.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import Modal from '../../internal/modal.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './drawer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Drawers slide in from a container to expose additional options and information.
|
||||
* @documentation https://shoelace.style/components/drawer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon-button
|
||||
*
|
||||
* @slot - The drawer's main content.
|
||||
* @slot label - The drawer's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
|
||||
*
|
||||
* @event sl-show - Emitted when the drawer opens.
|
||||
* @event sl-after-show - Emitted after the drawer opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the drawer closes.
|
||||
* @event sl-after-hide - Emitted after the drawer closes and all animations are complete.
|
||||
* @event sl-initial-focus - Emitted when the drawer opens and is ready to receive focus. Calling
|
||||
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
|
||||
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
|
||||
* close the drawer by clicking the close button, clicking the overlay, or pressing escape. Calling
|
||||
* `event.preventDefault()` will keep the drawer open. Avoid using this unless closing the drawer will result in
|
||||
* destructive behavior such as data loss.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart overlay - The overlay that covers the screen behind the drawer.
|
||||
* @csspart panel - The drawer's panel (where the drawer and its content are rendered).
|
||||
* @csspart header - The drawer's header. This element wraps the title and header actions.
|
||||
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
|
||||
* @csspart title - The drawer's title.
|
||||
* @csspart close-button - The close button, an `<sl-icon-button>`.
|
||||
* @csspart close-button__base - The close button's exported `base` part.
|
||||
* @csspart body - The drawer's body.
|
||||
* @csspart footer - The drawer's footer.
|
||||
*
|
||||
* @cssproperty --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.
|
||||
* @cssproperty --header-spacing - The amount of padding to use for the header.
|
||||
* @cssproperty --body-spacing - The amount of padding to use for the body.
|
||||
* @cssproperty --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.denyClose - The animation to use when a request to close the drawer is denied.
|
||||
* @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 ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, 'footer');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private modal = new Modal(this);
|
||||
private originalTrigger: HTMLElement | null;
|
||||
|
||||
@query('.drawer') drawer: HTMLElement;
|
||||
@query('.drawer__panel') panel: HTMLElement;
|
||||
@query('.drawer__overlay') overlay: HTMLElement;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can
|
||||
* use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The drawer's label as displayed in the header. You should always include a relevant label even when using
|
||||
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
|
||||
*/
|
||||
@property({ reflect: true }) label = '';
|
||||
|
||||
/** The direction from which the drawer will open. */
|
||||
@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
|
||||
* its parent element, set this attribute and add `position: relative` to the parent.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) contained = false;
|
||||
|
||||
/**
|
||||
* Removes the header. This will also remove the default close button, so please ensure you provide an easy,
|
||||
* accessible way for users to dismiss the drawer.
|
||||
*/
|
||||
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
|
||||
|
||||
firstUpdated() {
|
||||
this.drawer.hidden = !this.open;
|
||||
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
|
||||
if (!this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
|
||||
const slRequestClose = this.emit('sl-request-close', {
|
||||
cancelable: true,
|
||||
detail: { source }
|
||||
});
|
||||
|
||||
if (slRequestClose.defaultPrevented) {
|
||||
const animation = getAnimation(this, 'drawer.denyClose', { dir: this.localize.dir() });
|
||||
animateTo(this.panel, animation.keyframes, animation.options);
|
||||
return;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private addOpenListeners() {
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private removeOpenListeners() {
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
}
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (this.open && !this.contained && event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.requestClose('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
this.originalTrigger = document.activeElement as HTMLElement;
|
||||
|
||||
// Lock body scrolling only if the drawer isn't contained
|
||||
if (!this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
|
||||
// When the drawer is shown, Safari will attempt to set focus on whatever element has autofocus. This causes the
|
||||
// drawer's animation to jitter, so we'll temporarily remove the attribute, call `focus({ preventScroll: true })`
|
||||
// ourselves, and add the attribute back afterwards.
|
||||
//
|
||||
// Related: https://github.com/shoelace-style/shoelace/issues/693
|
||||
//
|
||||
const autoFocusTarget = this.querySelector('[autofocus]');
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.removeAttribute('autofocus');
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
|
||||
this.drawer.hidden = false;
|
||||
|
||||
// Set initial focus
|
||||
requestAnimationFrame(() => {
|
||||
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
|
||||
|
||||
if (!slInitialFocus.defaultPrevented) {
|
||||
// Set focus to the autofocus target and restore the attribute
|
||||
if (autoFocusTarget) {
|
||||
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
|
||||
} else {
|
||||
this.panel.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the autofocus attribute
|
||||
if (autoFocusTarget) {
|
||||
autoFocusTarget.setAttribute('autofocus', '');
|
||||
}
|
||||
});
|
||||
|
||||
const panelAnimation = getAnimation(this, `drawer.show${uppercaseFirstLetter(this.placement)}`, {
|
||||
dir: this.localize.dir()
|
||||
});
|
||||
const overlayAnimation = getAnimation(this, 'drawer.overlay.show', { dir: this.localize.dir() });
|
||||
await Promise.all([
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
|
||||
]);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
if (!this.contained) {
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
|
||||
await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]);
|
||||
const panelAnimation = getAnimation(this, `drawer.hide${uppercaseFirstLetter(this.placement)}`, {
|
||||
dir: this.localize.dir()
|
||||
});
|
||||
const overlayAnimation = getAnimation(this, 'drawer.overlay.hide', { dir: this.localize.dir() });
|
||||
|
||||
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
|
||||
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
|
||||
// unexpectedly. We'll unhide them after all animations have completed.
|
||||
await Promise.all([
|
||||
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
|
||||
this.overlay.hidden = true;
|
||||
}),
|
||||
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
|
||||
this.panel.hidden = true;
|
||||
})
|
||||
]);
|
||||
|
||||
this.drawer.hidden = true;
|
||||
|
||||
// Now that the dialog is hidden, restore the overlay and panel for next time
|
||||
this.overlay.hidden = false;
|
||||
this.panel.hidden = false;
|
||||
|
||||
// Restore focus to the original trigger
|
||||
const trigger = this.originalTrigger;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
setTimeout(() => trigger.focus());
|
||||
}
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('contained', { waitUntilFirstUpdate: true })
|
||||
handleNoModalChange() {
|
||||
if (this.open && !this.contained) {
|
||||
this.modal.activate();
|
||||
lockBodyScrolling(this);
|
||||
}
|
||||
|
||||
if (this.open && this.contained) {
|
||||
this.modal.deactivate();
|
||||
unlockBodyScrolling(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the drawer. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the drawer */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
drawer: true,
|
||||
'drawer--open': this.open,
|
||||
'drawer--top': this.placement === 'top',
|
||||
'drawer--end': this.placement === 'end',
|
||||
'drawer--bottom': this.placement === 'bottom',
|
||||
'drawer--start': this.placement === 'start',
|
||||
'drawer--contained': this.contained,
|
||||
'drawer--fixed': !this.contained,
|
||||
'drawer--rtl': this.localize.dir() === 'rtl',
|
||||
'drawer--has-footer': this.hasSlotController.test('footer')
|
||||
})}
|
||||
>
|
||||
<div part="overlay" class="drawer__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
|
||||
|
||||
<div
|
||||
part="panel"
|
||||
class="drawer__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden=${this.open ? 'false' : 'true'}
|
||||
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
|
||||
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
|
||||
tabindex="0"
|
||||
>
|
||||
${!this.noHeader
|
||||
? html`
|
||||
<header part="header" class="drawer__header">
|
||||
<h2 part="title" class="drawer__title" id="title">
|
||||
<!-- If there's no label, use an invisible character to prevent the header from collapsing -->
|
||||
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
|
||||
</h2>
|
||||
<div part="header-actions" class="drawer__header-actions">
|
||||
<slot name="header-actions"></slot>
|
||||
<sl-icon-button
|
||||
part="close-button"
|
||||
exportparts="base:close-button__base"
|
||||
class="drawer__close"
|
||||
name="x-lg"
|
||||
label=${this.localize.term('close')}
|
||||
library="system"
|
||||
@click=${() => this.requestClose('close-button')}
|
||||
></sl-icon-button>
|
||||
</div>
|
||||
</header>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<slot part="body" class="drawer__body"></slot>
|
||||
|
||||
<footer part="footer" class="drawer__footer">
|
||||
<slot name="footer"></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Top
|
||||
setDefaultAnimation('drawer.showTop', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '0 -100%' },
|
||||
{ opacity: 1, translate: '0 0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideTop', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0 0' },
|
||||
{ opacity: 0, translate: '0 -100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// End
|
||||
setDefaultAnimation('drawer.showEnd', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 0, translate: '-100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideEnd', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '100%' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '-100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Bottom
|
||||
setDefaultAnimation('drawer.showBottom', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '0 100%' },
|
||||
{ opacity: 1, translate: '0 0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideBottom', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0 0' },
|
||||
{ opacity: 0, translate: '0 100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Start
|
||||
setDefaultAnimation('drawer.showStart', {
|
||||
keyframes: [
|
||||
{ opacity: 0, translate: '-100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 0, translate: '100%' },
|
||||
{ opacity: 1, translate: '0' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('drawer.hideStart', {
|
||||
keyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '-100%' }
|
||||
],
|
||||
rtlKeyframes: [
|
||||
{ opacity: 1, translate: '0' },
|
||||
{ opacity: 0, translate: '100%' }
|
||||
],
|
||||
options: { duration: 250, easing: 'ease' }
|
||||
});
|
||||
|
||||
// Deny close
|
||||
setDefaultAnimation('drawer.denyClose', {
|
||||
keyframes: [{ scale: 1 }, { scale: 1.01 }, { scale: 1 }],
|
||||
options: { duration: 250 }
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
import SlDrawer from './drawer.component.js';
|
||||
export * from './drawer.component.js';
|
||||
export default SlDrawer;
|
||||
SlDrawer.define('sl-drawer');
|
||||
|
||||
444
src/components/dropdown/dropdown.component.ts
Normal file
444
src/components/dropdown/dropdown.component.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { getTabbableBoundary } from '../../internal/tabbable.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlPopup from '../popup/popup.component.js';
|
||||
import styles from './dropdown.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlButton from '../button/button.js';
|
||||
import type SlIconButton from '../icon-button/icon-button.js';
|
||||
import type SlMenu from '../menu/menu.js';
|
||||
import type SlSelectEvent from '../../events/sl-select.js';
|
||||
|
||||
/**
|
||||
* @summary Dropdowns expose additional content that "drops down" in a panel.
|
||||
* @documentation https://shoelace.style/components/dropdown
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-popup
|
||||
*
|
||||
* @slot - The dropdown's main content.
|
||||
* @slot trigger - The dropdown's trigger, usually a `<sl-button>` element.
|
||||
*
|
||||
* @event sl-show - Emitted when the dropdown opens.
|
||||
* @event sl-after-show - Emitted after the dropdown opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the dropdown closes.
|
||||
* @event sl-after-hide - Emitted after the dropdown closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart trigger - The container that wraps the trigger.
|
||||
* @csspart 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.
|
||||
*/
|
||||
export default class SlDropdown extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-popup': SlPopup };
|
||||
|
||||
@query('.dropdown') popup: SlPopup;
|
||||
@query('.dropdown__trigger') trigger: HTMLSlotElement;
|
||||
@query('.dropdown__panel') panel: HTMLSlotElement;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
|
||||
* can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
|
||||
* inside of the viewport.
|
||||
*/
|
||||
@property({ reflect: true }) placement:
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end' = 'bottom-start';
|
||||
|
||||
/** Disables the dropdown so the panel will not open. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/**
|
||||
* By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for
|
||||
* dropdowns that allow for multiple interactions.
|
||||
*/
|
||||
@property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;
|
||||
|
||||
/**
|
||||
* The dropdown will close when the user interacts outside of this element (e.g. clicking). Useful for composing other
|
||||
* components that use a dropdown internally.
|
||||
*/
|
||||
@property({ attribute: false }) containingElement?: HTMLElement;
|
||||
|
||||
/** The distance in pixels from which to offset the panel away from its trigger. */
|
||||
@property({ type: Number }) distance = 0;
|
||||
|
||||
/** The distance in pixels from which to offset the panel along its trigger. */
|
||||
@property({ type: Number }) skidding = 0;
|
||||
|
||||
/**
|
||||
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
|
||||
* `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios.
|
||||
*/
|
||||
@property({ type: Boolean }) hoist = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.containingElement) {
|
||||
this.containingElement = this;
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.panel.hidden = !this.open;
|
||||
|
||||
// If the dropdown is visible on init, update its position
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
this.popup.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeOpenListeners();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
focusOnTrigger() {
|
||||
const trigger = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
trigger.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getMenu() {
|
||||
return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as
|
||||
| SlMenu
|
||||
| undefined;
|
||||
}
|
||||
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape is pressed inside an open dropdown. We need to listen on the panel itself and stop propagation
|
||||
// in case any ancestors are also listening for this key.
|
||||
if (this.open && event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape or tab is pressed
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
event.stopPropagation();
|
||||
this.focusOnTrigger();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tabbing
|
||||
if (event.key === 'Tab') {
|
||||
// Tabbing within an open menu should close the dropdown and refocus the trigger
|
||||
if (this.open && document.activeElement?.tagName.toLowerCase() === 'sl-menu-item') {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tabbing outside of the containing element closes the panel
|
||||
//
|
||||
// If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
|
||||
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
|
||||
setTimeout(() => {
|
||||
const activeElement =
|
||||
this.containingElement?.getRootNode() instanceof ShadowRoot
|
||||
? document.activeElement?.shadowRoot?.activeElement
|
||||
: document.activeElement;
|
||||
|
||||
if (
|
||||
!this.containingElement ||
|
||||
activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
|
||||
) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentMouseDown = (event: MouseEvent) => {
|
||||
// Close when clicking outside of the containing element
|
||||
const path = event.composedPath();
|
||||
if (this.containingElement && !path.includes(this.containingElement)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handlePanelSelect = (event: SlSelectEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Hide the dropdown when a menu item is selected
|
||||
if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
};
|
||||
|
||||
handleTriggerClick() {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
}
|
||||
|
||||
async handleTriggerKeyDown(event: KeyboardEvent) {
|
||||
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
|
||||
// key again to hide the menu in case they don't want to make a selection.
|
||||
if ([' ', 'Enter'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
this.handleTriggerClick();
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = this.getMenu();
|
||||
|
||||
if (menu) {
|
||||
const menuItems = menu.getAllItems();
|
||||
const firstMenuItem = menuItems[0];
|
||||
const lastMenuItem = menuItems[menuItems.length - 1];
|
||||
|
||||
// When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a
|
||||
// selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for
|
||||
// faster navigation.
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
|
||||
// Show the menu if it's not already open
|
||||
if (!this.open) {
|
||||
this.show();
|
||||
|
||||
// Wait for the dropdown to open before focusing, but not the animation
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
// Focus on the first/last menu item after showing
|
||||
this.updateComplete.then(() => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Home') {
|
||||
menu.setCurrentItem(firstMenuItem);
|
||||
firstMenuItem.focus();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'End') {
|
||||
menu.setCurrentItem(lastMenuItem);
|
||||
lastMenuItem.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerKeyUp(event: KeyboardEvent) {
|
||||
// Prevent space from triggering a click event in Firefox
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerSlotChange() {
|
||||
this.updateAccessibleTrigger();
|
||||
}
|
||||
|
||||
//
|
||||
// Slotted triggers can be arbitrary content, but we need to link them to the dropdown panel with `aria-haspopup` and
|
||||
// `aria-expanded`. These must be applied to the "accessible trigger" (the tabbable portion of the trigger element
|
||||
// that gets slotted in) so screen readers will understand them. The accessible trigger could be the slotted element,
|
||||
// a child of the slotted element, or an element in the slotted element's shadow root.
|
||||
//
|
||||
// For example, the accessible trigger of an <sl-button> is a <button> located inside its shadow root.
|
||||
//
|
||||
// To determine this, we assume the first tabbable element in the trigger slot is the "accessible trigger."
|
||||
//
|
||||
updateAccessibleTrigger() {
|
||||
const assignedElements = this.trigger.assignedElements({ flatten: true }) as HTMLElement[];
|
||||
const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
|
||||
let target: HTMLElement;
|
||||
|
||||
if (accessibleTrigger) {
|
||||
switch (accessibleTrigger.tagName.toLowerCase()) {
|
||||
// Shoelace buttons have to update the internal button so it's announced correctly by screen readers
|
||||
case 'sl-button':
|
||||
case 'sl-icon-button':
|
||||
target = (accessibleTrigger as SlButton | SlIconButton).button;
|
||||
break;
|
||||
|
||||
default:
|
||||
target = accessibleTrigger;
|
||||
}
|
||||
|
||||
target.setAttribute('aria-haspopup', 'true');
|
||||
target.setAttribute('aria-expanded', this.open ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the dropdown panel. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the dropdown panel */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
|
||||
* is activated.
|
||||
*/
|
||||
reposition() {
|
||||
this.popup.reposition();
|
||||
}
|
||||
|
||||
addOpenListeners() {
|
||||
this.panel.addEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.addEventListener('keydown', this.handleKeyDown);
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
removeOpenListeners() {
|
||||
if (this.panel) {
|
||||
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.disabled) {
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateAccessibleTrigger();
|
||||
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
this.panel.hidden = false;
|
||||
this.popup.active = true;
|
||||
const { keyframes, options } = getAnimation(this, 'dropdown.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
const { keyframes, options } = getAnimation(this, 'dropdown.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
this.panel.hidden = true;
|
||||
this.popup.active = false;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<sl-popup
|
||||
part="base"
|
||||
id="dropdown"
|
||||
placement=${this.placement}
|
||||
distance=${this.distance}
|
||||
skidding=${this.skidding}
|
||||
strategy=${this.hoist ? 'fixed' : 'absolute'}
|
||||
flip
|
||||
shift
|
||||
auto-size="vertical"
|
||||
auto-size-padding="10"
|
||||
class=${classMap({
|
||||
dropdown: true,
|
||||
'dropdown--open': this.open
|
||||
})}
|
||||
>
|
||||
<slot
|
||||
name="trigger"
|
||||
slot="anchor"
|
||||
part="trigger"
|
||||
class="dropdown__trigger"
|
||||
@click=${this.handleTriggerClick}
|
||||
@keydown=${this.handleTriggerKeyDown}
|
||||
@keyup=${this.handleTriggerKeyUp}
|
||||
@slotchange=${this.handleTriggerSlotChange}
|
||||
></slot>
|
||||
|
||||
<div aria-hidden=${this.open ? 'false' : 'true'} aria-labelledby="dropdown">
|
||||
<slot part="panel" class="dropdown__panel"></slot>
|
||||
</div>
|
||||
</sl-popup>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('dropdown.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.9 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dropdown.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.9 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-dropdown': SlDropdown;
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ describe('<sl-dropdown>', () => {
|
||||
expect(el.open).to.be.true;
|
||||
});
|
||||
|
||||
it('should open on arrow navigation', async () => {
|
||||
it('should open on arrow down navigation', async () => {
|
||||
const el = await fixture<SlDropdown>(html`
|
||||
<sl-dropdown>
|
||||
<sl-button slot="trigger" caret>Toggle</sl-button>
|
||||
@@ -173,12 +173,35 @@ describe('<sl-dropdown>', () => {
|
||||
</sl-dropdown>
|
||||
`);
|
||||
const trigger = el.querySelector('sl-button')!;
|
||||
const firstMenuItem = el.querySelectorAll('sl-menu-item')[0];
|
||||
|
||||
trigger.focus();
|
||||
await sendKeys({ press: 'ArrowDown' });
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.open).to.be.true;
|
||||
expect(document.activeElement).to.equal(firstMenuItem);
|
||||
});
|
||||
|
||||
it('should open on arrow up navigation', async () => {
|
||||
const el = await fixture<SlDropdown>(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>
|
||||
</sl-dropdown>
|
||||
`);
|
||||
const trigger = el.querySelector('sl-button')!;
|
||||
const secondMenuItem = el.querySelectorAll('sl-menu-item')[1];
|
||||
|
||||
trigger.focus();
|
||||
await sendKeys({ press: 'ArrowUp' });
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.open).to.be.true;
|
||||
expect(document.activeElement).to.equal(secondMenuItem);
|
||||
});
|
||||
|
||||
it('should navigate to first focusable item on arrow navigation', async () => {
|
||||
|
||||
@@ -1,442 +1,4 @@
|
||||
import '../popup/popup.js';
|
||||
import { animateTo, stopAnimations } from '../../internal/animate.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
|
||||
import { getTabbableBoundary } from '../../internal/tabbable.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './dropdown.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlButton from '../button/button.js';
|
||||
import type SlIconButton from '../icon-button/icon-button.js';
|
||||
import type SlMenu from '../menu/menu.js';
|
||||
import type SlPopup from '../popup/popup.js';
|
||||
import type SlSelectEvent from '../../events/sl-select.js';
|
||||
|
||||
/**
|
||||
* @summary Dropdowns expose additional content that "drops down" in a panel.
|
||||
* @documentation https://shoelace.style/components/dropdown
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-popup
|
||||
*
|
||||
* @slot - The dropdown's main content.
|
||||
* @slot trigger - The dropdown's trigger, usually a `<sl-button>` element.
|
||||
*
|
||||
* @event sl-show - Emitted when the dropdown opens.
|
||||
* @event sl-after-show - Emitted after the dropdown opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the dropdown closes.
|
||||
* @event sl-after-hide - Emitted after the dropdown closes and all animations are complete.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart trigger - The container that wraps the trigger.
|
||||
* @csspart 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 ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('.dropdown') popup: SlPopup;
|
||||
@query('.dropdown__trigger') trigger: HTMLSlotElement;
|
||||
@query('.dropdown__panel') panel: HTMLSlotElement;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/**
|
||||
* Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
|
||||
* can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
|
||||
/**
|
||||
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
|
||||
* inside of the viewport.
|
||||
*/
|
||||
@property({ reflect: true }) placement:
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end' = 'bottom-start';
|
||||
|
||||
/** Disables the dropdown so the panel will not open. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/**
|
||||
* By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for
|
||||
* dropdowns that allow for multiple interactions.
|
||||
*/
|
||||
@property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;
|
||||
|
||||
/**
|
||||
* The dropdown will close when the user interacts outside of this element (e.g. clicking). Useful for composing other
|
||||
* components that use a dropdown internally.
|
||||
*/
|
||||
@property({ attribute: false }) containingElement?: HTMLElement;
|
||||
|
||||
/** The distance in pixels from which to offset the panel away from its trigger. */
|
||||
@property({ type: Number }) distance = 0;
|
||||
|
||||
/** The distance in pixels from which to offset the panel along its trigger. */
|
||||
@property({ type: Number }) skidding = 0;
|
||||
|
||||
/**
|
||||
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
|
||||
* `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios.
|
||||
*/
|
||||
@property({ type: Boolean }) hoist = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.containingElement) {
|
||||
this.containingElement = this;
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.panel.hidden = !this.open;
|
||||
|
||||
// If the dropdown is visible on init, update its position
|
||||
if (this.open) {
|
||||
this.addOpenListeners();
|
||||
this.popup.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeOpenListeners();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
focusOnTrigger() {
|
||||
const trigger = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
|
||||
if (typeof trigger?.focus === 'function') {
|
||||
trigger.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getMenu() {
|
||||
return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as
|
||||
| SlMenu
|
||||
| undefined;
|
||||
}
|
||||
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape is pressed inside an open dropdown. We need to listen on the panel itself and stop propagation
|
||||
// in case any ancestors are also listening for this key.
|
||||
if (this.open && event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
// Close when escape or tab is pressed
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
event.stopPropagation();
|
||||
this.focusOnTrigger();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tabbing
|
||||
if (event.key === 'Tab') {
|
||||
// Tabbing within an open menu should close the dropdown and refocus the trigger
|
||||
if (this.open && document.activeElement?.tagName.toLowerCase() === 'sl-menu-item') {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tabbing outside of the containing element closes the panel
|
||||
//
|
||||
// If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
|
||||
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
|
||||
setTimeout(() => {
|
||||
const activeElement =
|
||||
this.containingElement?.getRootNode() instanceof ShadowRoot
|
||||
? document.activeElement?.shadowRoot?.activeElement
|
||||
: document.activeElement;
|
||||
|
||||
if (
|
||||
!this.containingElement ||
|
||||
activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
|
||||
) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private handleDocumentMouseDown = (event: MouseEvent) => {
|
||||
// Close when clicking outside of the containing element
|
||||
const path = event.composedPath();
|
||||
if (this.containingElement && !path.includes(this.containingElement)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private handlePanelSelect = (event: SlSelectEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Hide the dropdown when a menu item is selected
|
||||
if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
|
||||
this.hide();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
};
|
||||
|
||||
handleTriggerClick() {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerKeyDown(event: KeyboardEvent) {
|
||||
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
|
||||
// key again to hide the menu in case they don't want to make a selection.
|
||||
if ([' ', 'Enter'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
this.handleTriggerClick();
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = this.getMenu();
|
||||
|
||||
if (menu) {
|
||||
const menuItems = menu.getAllItems();
|
||||
const firstMenuItem = menuItems[0];
|
||||
const lastMenuItem = menuItems[menuItems.length - 1];
|
||||
|
||||
// When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a
|
||||
// selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for
|
||||
// faster navigation.
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
|
||||
// Show the menu if it's not already open
|
||||
if (!this.open) {
|
||||
this.show();
|
||||
}
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
// Focus on the first/last menu item after showing
|
||||
this.updateComplete.then(() => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Home') {
|
||||
menu.setCurrentItem(firstMenuItem);
|
||||
firstMenuItem.focus();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'End') {
|
||||
menu.setCurrentItem(lastMenuItem);
|
||||
lastMenuItem.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerKeyUp(event: KeyboardEvent) {
|
||||
// Prevent space from triggering a click event in Firefox
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerSlotChange() {
|
||||
this.updateAccessibleTrigger();
|
||||
}
|
||||
|
||||
//
|
||||
// Slotted triggers can be arbitrary content, but we need to link them to the dropdown panel with `aria-haspopup` and
|
||||
// `aria-expanded`. These must be applied to the "accessible trigger" (the tabbable portion of the trigger element
|
||||
// that gets slotted in) so screen readers will understand them. The accessible trigger could be the slotted element,
|
||||
// a child of the slotted element, or an element in the slotted element's shadow root.
|
||||
//
|
||||
// For example, the accessible trigger of an <sl-button> is a <button> located inside its shadow root.
|
||||
//
|
||||
// To determine this, we assume the first tabbable element in the trigger slot is the "accessible trigger."
|
||||
//
|
||||
updateAccessibleTrigger() {
|
||||
const assignedElements = this.trigger.assignedElements({ flatten: true }) as HTMLElement[];
|
||||
const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
|
||||
let target: HTMLElement;
|
||||
|
||||
if (accessibleTrigger) {
|
||||
switch (accessibleTrigger.tagName.toLowerCase()) {
|
||||
// Shoelace buttons have to update the internal button so it's announced correctly by screen readers
|
||||
case 'sl-button':
|
||||
case 'sl-icon-button':
|
||||
target = (accessibleTrigger as SlButton | SlIconButton).button;
|
||||
break;
|
||||
|
||||
default:
|
||||
target = accessibleTrigger;
|
||||
}
|
||||
|
||||
target.setAttribute('aria-haspopup', 'true');
|
||||
target.setAttribute('aria-expanded', this.open ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the dropdown panel. */
|
||||
async show() {
|
||||
if (this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
return waitForEvent(this, 'sl-after-show');
|
||||
}
|
||||
|
||||
/** Hides the dropdown panel */
|
||||
async hide() {
|
||||
if (!this.open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
|
||||
* is activated.
|
||||
*/
|
||||
reposition() {
|
||||
this.popup.reposition();
|
||||
}
|
||||
|
||||
addOpenListeners() {
|
||||
this.panel.addEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.addEventListener('keydown', this.handleKeyDown);
|
||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
removeOpenListeners() {
|
||||
if (this.panel) {
|
||||
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
|
||||
this.panel.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
document.removeEventListener('keydown', this.handleDocumentKeyDown);
|
||||
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
@watch('open', { waitUntilFirstUpdate: true })
|
||||
async handleOpenChange() {
|
||||
if (this.disabled) {
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateAccessibleTrigger();
|
||||
|
||||
if (this.open) {
|
||||
// Show
|
||||
this.emit('sl-show');
|
||||
this.addOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
this.panel.hidden = false;
|
||||
this.popup.active = true;
|
||||
const { keyframes, options } = getAnimation(this, 'dropdown.show', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
|
||||
this.emit('sl-after-show');
|
||||
} else {
|
||||
// Hide
|
||||
this.emit('sl-hide');
|
||||
this.removeOpenListeners();
|
||||
|
||||
await stopAnimations(this);
|
||||
const { keyframes, options } = getAnimation(this, 'dropdown.hide', { dir: this.localize.dir() });
|
||||
await animateTo(this.popup.popup, keyframes, options);
|
||||
this.panel.hidden = true;
|
||||
this.popup.active = false;
|
||||
|
||||
this.emit('sl-after-hide');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<sl-popup
|
||||
part="base"
|
||||
id="dropdown"
|
||||
placement=${this.placement}
|
||||
distance=${this.distance}
|
||||
skidding=${this.skidding}
|
||||
strategy=${this.hoist ? 'fixed' : 'absolute'}
|
||||
flip
|
||||
shift
|
||||
auto-size="vertical"
|
||||
auto-size-padding="10"
|
||||
class=${classMap({
|
||||
dropdown: true,
|
||||
'dropdown--open': this.open
|
||||
})}
|
||||
>
|
||||
<slot
|
||||
name="trigger"
|
||||
slot="anchor"
|
||||
part="trigger"
|
||||
class="dropdown__trigger"
|
||||
@click=${this.handleTriggerClick}
|
||||
@keydown=${this.handleTriggerKeyDown}
|
||||
@keyup=${this.handleTriggerKeyUp}
|
||||
@slotchange=${this.handleTriggerSlotChange}
|
||||
></slot>
|
||||
|
||||
<div aria-hidden=${this.open ? 'false' : 'true'} aria-labelledby="dropdown">
|
||||
<slot part="panel" class="dropdown__panel"></slot>
|
||||
</div>
|
||||
</sl-popup>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultAnimation('dropdown.show', {
|
||||
keyframes: [
|
||||
{ opacity: 0, scale: 0.9 },
|
||||
{ opacity: 1, scale: 1 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
setDefaultAnimation('dropdown.hide', {
|
||||
keyframes: [
|
||||
{ opacity: 1, scale: 1 },
|
||||
{ opacity: 0, scale: 0.9 }
|
||||
],
|
||||
options: { duration: 100, easing: 'ease' }
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-dropdown': SlDropdown;
|
||||
}
|
||||
}
|
||||
import SlDropdown from './dropdown.component.js';
|
||||
export * from './dropdown.component.js';
|
||||
export default SlDropdown;
|
||||
SlDropdown.define('sl-dropdown');
|
||||
|
||||
47
src/components/format-bytes/format-bytes.component.ts
Normal file
47
src/components/format-bytes/format-bytes.component.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a number as a human readable bytes value.
|
||||
* @documentation https://shoelace.style/components/format-bytes
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
export default class SlFormatBytes extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The number to format in bytes. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The type of unit to display. */
|
||||
@property() unit: 'byte' | 'bit' = 'byte';
|
||||
|
||||
/** Determines how to display the result, e.g. "100 bytes", "100 b", or "100b". */
|
||||
@property() display: 'long' | 'short' | 'narrow' = 'short';
|
||||
|
||||
render() {
|
||||
if (isNaN(this.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bitPrefixes = ['', 'kilo', 'mega', 'giga', 'tera']; // petabit isn't a supported unit
|
||||
const bytePrefixes = ['', 'kilo', 'mega', 'giga', 'tera', 'peta'];
|
||||
const prefix = this.unit === 'bit' ? bitPrefixes : bytePrefixes;
|
||||
const index = Math.max(0, Math.min(Math.floor(Math.log10(this.value) / 3), prefix.length - 1));
|
||||
const unit = prefix[index] + this.unit;
|
||||
const valueToFormat = parseFloat((this.value / Math.pow(1000, index)).toPrecision(3));
|
||||
|
||||
return this.localize.number(valueToFormat, {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: this.display
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-bytes': SlFormatBytes;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a number as a human readable bytes value.
|
||||
* @documentation https://shoelace.style/components/format-bytes
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
@customElement('sl-format-bytes')
|
||||
export default class SlFormatBytes extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The number to format in bytes. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The type of unit to display. */
|
||||
@property() unit: 'byte' | 'bit' = 'byte';
|
||||
|
||||
/** Determines how to display the result, e.g. "100 bytes", "100 b", or "100b". */
|
||||
@property() display: 'long' | 'short' | 'narrow' = 'short';
|
||||
|
||||
render() {
|
||||
if (isNaN(this.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bitPrefixes = ['', 'kilo', 'mega', 'giga', 'tera']; // petabit isn't a supported unit
|
||||
const bytePrefixes = ['', 'kilo', 'mega', 'giga', 'tera', 'peta'];
|
||||
const prefix = this.unit === 'bit' ? bitPrefixes : bytePrefixes;
|
||||
const index = Math.max(0, Math.min(Math.floor(Math.log10(this.value) / 3), prefix.length - 1));
|
||||
const unit = prefix[index] + this.unit;
|
||||
const valueToFormat = parseFloat((this.value / Math.pow(1000, index)).toPrecision(3));
|
||||
|
||||
return this.localize.number(valueToFormat, {
|
||||
style: 'unit',
|
||||
unit,
|
||||
unitDisplay: this.display
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-bytes': SlFormatBytes;
|
||||
}
|
||||
}
|
||||
import SlFormatBytes from './format-bytes.component.js';
|
||||
export * from './format-bytes.component.js';
|
||||
export default SlFormatBytes;
|
||||
SlFormatBytes.define('sl-format-bytes');
|
||||
|
||||
88
src/components/format-date/format-date.component.ts
Normal file
88
src/components/format-date/format-date.component.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a date/time using the specified locale and options.
|
||||
* @documentation https://shoelace.style/components/format-date
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
export default class SlFormatDate extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/**
|
||||
* The date/time to format. If not set, the current date and time will be used. When passing a string, it's strongly
|
||||
* recommended to use the ISO 8601 format to ensure timezones are handled correctly. To convert a date to this format
|
||||
* in JavaScript, use [`date.toISOString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString).
|
||||
*/
|
||||
@property() date: Date | string = new Date();
|
||||
|
||||
/** The format for displaying the weekday. */
|
||||
@property() weekday: 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the era. */
|
||||
@property() era: 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the year. */
|
||||
@property() year: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the month. */
|
||||
@property() month: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the day. */
|
||||
@property() day: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the hour. */
|
||||
@property() hour: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the minute. */
|
||||
@property() minute: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the second. */
|
||||
@property() second: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the time. */
|
||||
@property({ attribute: 'time-zone-name' }) timeZoneName: 'short' | 'long';
|
||||
|
||||
/** The time zone to express the time in. */
|
||||
@property({ attribute: 'time-zone' }) timeZone: string;
|
||||
|
||||
/** The format for displaying the hour. */
|
||||
@property({ attribute: 'hour-format' }) hourFormat: 'auto' | '12' | '24' = 'auto';
|
||||
|
||||
render() {
|
||||
const date = new Date(this.date);
|
||||
const hour12 = this.hourFormat === 'auto' ? undefined : this.hourFormat === '12';
|
||||
|
||||
// Check for an invalid date
|
||||
if (isNaN(date.getMilliseconds())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`
|
||||
<time datetime=${date.toISOString()}>
|
||||
${this.localize.date(date, {
|
||||
weekday: this.weekday,
|
||||
era: this.era,
|
||||
year: this.year,
|
||||
month: this.month,
|
||||
day: this.day,
|
||||
hour: this.hour,
|
||||
minute: this.minute,
|
||||
second: this.second,
|
||||
timeZoneName: this.timeZoneName,
|
||||
timeZone: this.timeZone,
|
||||
hour12: hour12
|
||||
})}
|
||||
</time>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-date': SlFormatDate;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a date/time using the specified locale and options.
|
||||
* @documentation https://shoelace.style/components/format-date
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
@customElement('sl-format-date')
|
||||
export default class SlFormatDate extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/**
|
||||
* The date/time to format. If not set, the current date and time will be used. When passing a string, it's strongly
|
||||
* recommended to use the ISO 8601 format to ensure timezones are handled correctly. To convert a date to this format
|
||||
* in JavaScript, use [`date.toISOString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString).
|
||||
*/
|
||||
@property() date: Date | string = new Date();
|
||||
|
||||
/** The format for displaying the weekday. */
|
||||
@property() weekday: 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the era. */
|
||||
@property() era: 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the year. */
|
||||
@property() year: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the month. */
|
||||
@property() month: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long';
|
||||
|
||||
/** The format for displaying the day. */
|
||||
@property() day: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the hour. */
|
||||
@property() hour: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the minute. */
|
||||
@property() minute: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the second. */
|
||||
@property() second: 'numeric' | '2-digit';
|
||||
|
||||
/** The format for displaying the time. */
|
||||
@property({ attribute: 'time-zone-name' }) timeZoneName: 'short' | 'long';
|
||||
|
||||
/** The time zone to express the time in. */
|
||||
@property({ attribute: 'time-zone' }) timeZone: string;
|
||||
|
||||
/** The format for displaying the hour. */
|
||||
@property({ attribute: 'hour-format' }) hourFormat: 'auto' | '12' | '24' = 'auto';
|
||||
|
||||
render() {
|
||||
const date = new Date(this.date);
|
||||
const hour12 = this.hourFormat === 'auto' ? undefined : this.hourFormat === '12';
|
||||
|
||||
// Check for an invalid date
|
||||
if (isNaN(date.getMilliseconds())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`
|
||||
<time datetime=${date.toISOString()}>
|
||||
${this.localize.date(date, {
|
||||
weekday: this.weekday,
|
||||
era: this.era,
|
||||
year: this.year,
|
||||
month: this.month,
|
||||
day: this.day,
|
||||
hour: this.hour,
|
||||
minute: this.minute,
|
||||
second: this.second,
|
||||
timeZoneName: this.timeZoneName,
|
||||
timeZone: this.timeZone,
|
||||
hour12: hour12
|
||||
})}
|
||||
</time>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-date': SlFormatDate;
|
||||
}
|
||||
}
|
||||
import SlFormatDate from './format-date.component.js';
|
||||
export * from './format-date.component.js';
|
||||
export default SlFormatDate;
|
||||
SlFormatDate.define('sl-format-date');
|
||||
|
||||
67
src/components/format-number/format-number.component.ts
Normal file
67
src/components/format-number/format-number.component.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a number using the specified locale and options.
|
||||
* @documentation https://shoelace.style/components/format-number
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
export default class SlFormatNumber extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The number to format. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The formatting style to use. */
|
||||
@property() type: 'currency' | 'decimal' | 'percent' = 'decimal';
|
||||
|
||||
/** Turns off grouping separators. */
|
||||
@property({ attribute: 'no-grouping', type: Boolean }) noGrouping = false;
|
||||
|
||||
/** The [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code to use when formatting. */
|
||||
@property() currency = 'USD';
|
||||
|
||||
/** How to display the currency. */
|
||||
@property({ attribute: 'currency-display' }) currencyDisplay: 'symbol' | 'narrowSymbol' | 'code' | 'name' = 'symbol';
|
||||
|
||||
/** The minimum number of integer digits to use. Possible values are 1-21. */
|
||||
@property({ attribute: 'minimum-integer-digits', type: Number }) minimumIntegerDigits: number;
|
||||
|
||||
/** The minimum number of fraction digits to use. Possible values are 0-20. */
|
||||
@property({ attribute: 'minimum-fraction-digits', type: Number }) minimumFractionDigits: number;
|
||||
|
||||
/** The maximum number of fraction digits to use. Possible values are 0-0. */
|
||||
@property({ attribute: 'maximum-fraction-digits', type: Number }) maximumFractionDigits: number;
|
||||
|
||||
/** The minimum number of significant digits to use. Possible values are 1-21. */
|
||||
@property({ attribute: 'minimum-significant-digits', type: Number }) minimumSignificantDigits: number;
|
||||
|
||||
/** The maximum number of significant digits to use,. Possible values are 1-21. */
|
||||
@property({ attribute: 'maximum-significant-digits', type: Number }) maximumSignificantDigits: number;
|
||||
|
||||
render() {
|
||||
if (isNaN(this.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.localize.number(this.value, {
|
||||
style: this.type,
|
||||
currency: this.currency,
|
||||
currencyDisplay: this.currencyDisplay,
|
||||
useGrouping: !this.noGrouping,
|
||||
minimumIntegerDigits: this.minimumIntegerDigits,
|
||||
minimumFractionDigits: this.minimumFractionDigits,
|
||||
maximumFractionDigits: this.maximumFractionDigits,
|
||||
minimumSignificantDigits: this.minimumSignificantDigits,
|
||||
maximumSignificantDigits: this.maximumSignificantDigits
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-number': SlFormatNumber;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Formats a number using the specified locale and options.
|
||||
* @documentation https://shoelace.style/components/format-number
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*/
|
||||
@customElement('sl-format-number')
|
||||
export default class SlFormatNumber extends ShoelaceElement {
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
/** The number to format. */
|
||||
@property({ type: Number }) value = 0;
|
||||
|
||||
/** The formatting style to use. */
|
||||
@property() type: 'currency' | 'decimal' | 'percent' = 'decimal';
|
||||
|
||||
/** Turns off grouping separators. */
|
||||
@property({ attribute: 'no-grouping', type: Boolean }) noGrouping = false;
|
||||
|
||||
/** The [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code to use when formatting. */
|
||||
@property() currency = 'USD';
|
||||
|
||||
/** How to display the currency. */
|
||||
@property({ attribute: 'currency-display' }) currencyDisplay: 'symbol' | 'narrowSymbol' | 'code' | 'name' = 'symbol';
|
||||
|
||||
/** The minimum number of integer digits to use. Possible values are 1-21. */
|
||||
@property({ attribute: 'minimum-integer-digits', type: Number }) minimumIntegerDigits: number;
|
||||
|
||||
/** The minimum number of fraction digits to use. Possible values are 0-20. */
|
||||
@property({ attribute: 'minimum-fraction-digits', type: Number }) minimumFractionDigits: number;
|
||||
|
||||
/** The maximum number of fraction digits to use. Possible values are 0-0. */
|
||||
@property({ attribute: 'maximum-fraction-digits', type: Number }) maximumFractionDigits: number;
|
||||
|
||||
/** The minimum number of significant digits to use. Possible values are 1-21. */
|
||||
@property({ attribute: 'minimum-significant-digits', type: Number }) minimumSignificantDigits: number;
|
||||
|
||||
/** The maximum number of significant digits to use,. Possible values are 1-21. */
|
||||
@property({ attribute: 'maximum-significant-digits', type: Number }) maximumSignificantDigits: number;
|
||||
|
||||
render() {
|
||||
if (isNaN(this.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.localize.number(this.value, {
|
||||
style: this.type,
|
||||
currency: this.currency,
|
||||
currencyDisplay: this.currencyDisplay,
|
||||
useGrouping: !this.noGrouping,
|
||||
minimumIntegerDigits: this.minimumIntegerDigits,
|
||||
minimumFractionDigits: this.minimumFractionDigits,
|
||||
maximumFractionDigits: this.maximumFractionDigits,
|
||||
minimumSignificantDigits: this.minimumSignificantDigits,
|
||||
maximumSignificantDigits: this.maximumSignificantDigits
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-format-number': SlFormatNumber;
|
||||
}
|
||||
}
|
||||
import SlFormatNumber from './format-number.component.js';
|
||||
export * from './format-number.component.js';
|
||||
export default SlFormatNumber;
|
||||
SlFormatNumber.define('sl-format-number');
|
||||
|
||||
136
src/components/icon-button/icon-button.component.ts
Normal file
136
src/components/icon-button/icon-button.component.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './icon-button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Icons buttons are simple, icon-only buttons that can be used for actions and in toolbars.
|
||||
* @documentation https://shoelace.style/components/icon-button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-blur - Emitted when the icon button loses focus.
|
||||
* @event sl-focus - Emitted when the icon button gains focus.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlIconButton extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
|
||||
/** The name of the icon to draw. Available names depend on the icon library being used. */
|
||||
@property() name?: string;
|
||||
|
||||
/** The name of a registered custom icon library. */
|
||||
@property() library?: string;
|
||||
|
||||
/**
|
||||
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
|
||||
* can result in XSS attacks.
|
||||
*/
|
||||
@property() src?: string;
|
||||
|
||||
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
||||
@property() href?: string;
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is set. */
|
||||
@property() target?: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
|
||||
@property() download?: string;
|
||||
|
||||
/**
|
||||
* A description that gets read by assistive devices. For optimal accessibility, you should always include a label
|
||||
* that describes what the icon button does.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** Disables the button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (this.disabled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulates a click on the icon button. */
|
||||
click() {
|
||||
this.button.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the icon button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.button.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the icon button. */
|
||||
blur() {
|
||||
this.button.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLink = this.href ? true : false;
|
||||
const tag = isLink ? literal`a` : literal`button`;
|
||||
|
||||
/* eslint-disable lit/binding-positions, lit/no-invalid-html */
|
||||
return html`
|
||||
<${tag}
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'icon-button': true,
|
||||
'icon-button--disabled': !isLink && this.disabled,
|
||||
'icon-button--focused': this.hasFocus
|
||||
})}
|
||||
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
|
||||
type=${ifDefined(isLink ? undefined : 'button')}
|
||||
href=${ifDefined(isLink ? this.href : undefined)}
|
||||
target=${ifDefined(isLink ? this.target : undefined)}
|
||||
download=${ifDefined(isLink ? this.download : undefined)}
|
||||
rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)}
|
||||
role=${ifDefined(isLink ? undefined : 'button')}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
aria-label="${this.label}"
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<sl-icon
|
||||
class="icon-button__icon"
|
||||
name=${ifDefined(this.name)}
|
||||
library=${ifDefined(this.library)}
|
||||
src=${ifDefined(this.src)}
|
||||
aria-hidden="true"
|
||||
></sl-icon>
|
||||
</${tag}>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-icon-button': SlIconButton;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './icon-button.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Icons buttons are simple, icon-only buttons that can be used for actions and in toolbars.
|
||||
* @documentation https://shoelace.style/components/icon-button
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @event sl-blur - Emitted when the icon button loses focus.
|
||||
* @event sl-focus - Emitted when the icon button gains focus.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-icon-button')
|
||||
export default class SlIconButton extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
|
||||
/** The name of the icon to draw. Available names depend on the icon library being used. */
|
||||
@property() name?: string;
|
||||
|
||||
/** The name of a registered custom icon library. */
|
||||
@property() library?: string;
|
||||
|
||||
/**
|
||||
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
|
||||
* can result in XSS attacks.
|
||||
*/
|
||||
@property() src?: string;
|
||||
|
||||
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
||||
@property() href?: string;
|
||||
|
||||
/** Tells the browser where to open the link. Only used when `href` is set. */
|
||||
@property() target?: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
|
||||
@property() download?: string;
|
||||
|
||||
/**
|
||||
* A description that gets read by assistive devices. For optimal accessibility, you should always include a label
|
||||
* that describes what the icon button does.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** Disables the button. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (this.disabled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulates a click on the icon button. */
|
||||
click() {
|
||||
this.button.click();
|
||||
}
|
||||
|
||||
/** Sets focus on the icon button. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.button.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the icon button. */
|
||||
blur() {
|
||||
this.button.blur();
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLink = this.href ? true : false;
|
||||
const tag = isLink ? literal`a` : literal`button`;
|
||||
|
||||
/* eslint-disable lit/binding-positions, lit/no-invalid-html */
|
||||
return html`
|
||||
<${tag}
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'icon-button': true,
|
||||
'icon-button--disabled': !isLink && this.disabled,
|
||||
'icon-button--focused': this.hasFocus
|
||||
})}
|
||||
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
|
||||
type=${ifDefined(isLink ? undefined : 'button')}
|
||||
href=${ifDefined(isLink ? this.href : undefined)}
|
||||
target=${ifDefined(isLink ? this.target : undefined)}
|
||||
download=${ifDefined(isLink ? this.download : undefined)}
|
||||
rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)}
|
||||
role=${ifDefined(isLink ? undefined : 'button')}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
aria-label="${this.label}"
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<sl-icon
|
||||
class="icon-button__icon"
|
||||
name=${ifDefined(this.name)}
|
||||
library=${ifDefined(this.library)}
|
||||
src=${ifDefined(this.src)}
|
||||
aria-hidden="true"
|
||||
></sl-icon>
|
||||
</${tag}>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-icon-button': SlIconButton;
|
||||
}
|
||||
}
|
||||
import SlIconButton from './icon-button.component.js';
|
||||
export * from './icon-button.component.js';
|
||||
export default SlIconButton;
|
||||
SlIconButton.define('sl-icon-button');
|
||||
|
||||
189
src/components/icon/icon.component.ts
Normal file
189
src/components/icon/icon.component.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { getIconLibrary, type IconLibrary, unwatchIcon, watchIcon } from './library.js';
|
||||
import { html } from 'lit';
|
||||
import { isTemplateResult } from 'lit/directive-helpers.js';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './icon.styles.js';
|
||||
|
||||
import type { CSSResultGroup, HTMLTemplateResult } from 'lit';
|
||||
|
||||
const CACHEABLE_ERROR = Symbol();
|
||||
const RETRYABLE_ERROR = Symbol();
|
||||
type SVGResult = HTMLTemplateResult | SVGSVGElement | typeof RETRYABLE_ERROR | typeof CACHEABLE_ERROR;
|
||||
|
||||
let parser: DOMParser;
|
||||
const iconCache = new Map<string, Promise<SVGResult>>();
|
||||
|
||||
/**
|
||||
* @summary Icons are symbols that can be used to represent various options within an application.
|
||||
* @documentation https://shoelace.style/components/icon
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-load - Emitted when the icon has loaded. When using `spriteSheet: true` this will not emit.
|
||||
* @event sl-error - Emitted when the icon fails to load due to an error. When using `spriteSheet: true` this will not emit.
|
||||
*
|
||||
* @csspart svg - The internal SVG element.
|
||||
* @csspart use - The <use> element generated when using `spriteSheet: true`
|
||||
*/
|
||||
export default class SlIcon extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private initialRender = false;
|
||||
|
||||
/** Given a URL, this function returns the resulting SVG element or an appropriate error symbol. */
|
||||
private async resolveIcon(url: string, library?: IconLibrary): Promise<SVGResult> {
|
||||
let fileData: Response;
|
||||
|
||||
if (library?.spriteSheet) {
|
||||
return html`<svg part="svg">
|
||||
<use part="use" href="${url}"></use>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
try {
|
||||
fileData = await fetch(url, { mode: 'cors' });
|
||||
if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR;
|
||||
} catch {
|
||||
return RETRYABLE_ERROR;
|
||||
}
|
||||
|
||||
try {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = await fileData.text();
|
||||
|
||||
const svg = div.firstElementChild;
|
||||
if (svg?.tagName?.toLowerCase() !== 'svg') return CACHEABLE_ERROR;
|
||||
|
||||
if (!parser) parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svg.outerHTML, 'text/html');
|
||||
|
||||
const svgEl = doc.body.querySelector('svg');
|
||||
if (!svgEl) return CACHEABLE_ERROR;
|
||||
|
||||
svgEl.part.add('svg');
|
||||
return document.adoptNode(svgEl);
|
||||
} catch {
|
||||
return CACHEABLE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
@state() private svg: SVGElement | HTMLTemplateResult | null = null;
|
||||
|
||||
/** The name of the icon to draw. Available names depend on the icon library being used. */
|
||||
@property({ reflect: true }) name?: string;
|
||||
|
||||
/**
|
||||
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
|
||||
* can result in XSS attacks.
|
||||
*/
|
||||
@property() src?: string;
|
||||
|
||||
/**
|
||||
* An alternate description to use for assistive devices. If omitted, the icon will be considered presentational and
|
||||
* ignored by assistive devices.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** The name of a registered custom icon library. */
|
||||
@property({ reflect: true }) library = 'default';
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
watchIcon(this);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.initialRender = true;
|
||||
this.setIcon();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unwatchIcon(this);
|
||||
}
|
||||
|
||||
private getUrl() {
|
||||
const library = getIconLibrary(this.library);
|
||||
if (this.name && library) {
|
||||
return library.resolver(this.name);
|
||||
}
|
||||
return this.src;
|
||||
}
|
||||
|
||||
@watch('label')
|
||||
handleLabelChange() {
|
||||
const hasLabel = typeof this.label === 'string' && this.label.length > 0;
|
||||
|
||||
if (hasLabel) {
|
||||
this.setAttribute('role', 'img');
|
||||
this.setAttribute('aria-label', this.label);
|
||||
this.removeAttribute('aria-hidden');
|
||||
} else {
|
||||
this.removeAttribute('role');
|
||||
this.removeAttribute('aria-label');
|
||||
this.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
@watch(['name', 'src', 'library'])
|
||||
async setIcon() {
|
||||
const library = getIconLibrary(this.library);
|
||||
const url = this.getUrl();
|
||||
|
||||
if (!url) {
|
||||
this.svg = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let iconResolver = iconCache.get(url);
|
||||
if (!iconResolver) {
|
||||
iconResolver = this.resolveIcon(url, library);
|
||||
iconCache.set(url, iconResolver);
|
||||
}
|
||||
|
||||
// If we haven't rendered yet, exit early. This avoids unnecessary work due to watching multiple props.
|
||||
if (!this.initialRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = await iconResolver;
|
||||
|
||||
if (svg === RETRYABLE_ERROR) {
|
||||
iconCache.delete(url);
|
||||
}
|
||||
|
||||
if (url !== this.getUrl()) {
|
||||
// If the url has changed while fetching the icon, ignore this request
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTemplateResult(svg)) {
|
||||
this.svg = svg;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (svg) {
|
||||
case RETRYABLE_ERROR:
|
||||
case CACHEABLE_ERROR:
|
||||
this.svg = null;
|
||||
this.emit('sl-error');
|
||||
break;
|
||||
default:
|
||||
this.svg = svg.cloneNode(true) as SVGElement;
|
||||
library?.mutator?.(this.svg);
|
||||
this.emit('sl-load');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.svg;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-icon': SlIcon;
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ describe('<sl-icon>', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('svg spritesheets', () => {
|
||||
describe('svg sprite sheets', () => {
|
||||
// For some reason ESLint wants to fail in CI here, but works locally.
|
||||
/* eslint-disable */
|
||||
it('Should properly grab an SVG and render it from bootstrap icons', async () => {
|
||||
@@ -175,8 +175,8 @@ describe('<sl-icon>', () => {
|
||||
expect(svg).to.be.instanceof(SVGElement);
|
||||
expect(use).to.be.instanceof(SVGUseElement);
|
||||
|
||||
// This is kind of hacky...but with no way to check "load", we just do a timeout :shrug:
|
||||
await aTimeout(200);
|
||||
// This is kind of hacky...but with no way to check "load", we just use a timeout
|
||||
await aTimeout(1000);
|
||||
|
||||
// Theres no way to really test that the icon rendered properly. We just gotta trust the browser to do it's thing :)
|
||||
// However, we can check the <use> size. It should be greater than 0x0 if loaded properly.
|
||||
@@ -192,7 +192,7 @@ describe('<sl-icon>', () => {
|
||||
spriteSheet: true
|
||||
});
|
||||
|
||||
const el = await fixture<SlIcon>(html`<sl-icon name="non-existant" library="sprite"></sl-icon>`);
|
||||
const el = await fixture<SlIcon>(html`<sl-icon name="non-existent" library="sprite"></sl-icon>`);
|
||||
|
||||
await elementUpdated(el);
|
||||
|
||||
|
||||
@@ -1,190 +1,4 @@
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { getIconLibrary, type IconLibrary, unwatchIcon, watchIcon } from './library.js';
|
||||
import { html } from 'lit';
|
||||
import { isTemplateResult } from 'lit/directive-helpers.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './icon.styles.js';
|
||||
|
||||
import type { CSSResultGroup, HTMLTemplateResult } from 'lit';
|
||||
|
||||
const CACHEABLE_ERROR = Symbol();
|
||||
const RETRYABLE_ERROR = Symbol();
|
||||
type SVGResult = HTMLTemplateResult | SVGSVGElement | typeof RETRYABLE_ERROR | typeof CACHEABLE_ERROR;
|
||||
|
||||
let parser: DOMParser;
|
||||
const iconCache = new Map<string, Promise<SVGResult>>();
|
||||
|
||||
/**
|
||||
* @summary Icons are symbols that can be used to represent various options within an application.
|
||||
* @documentation https://shoelace.style/components/icon
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-load - Emitted when the icon has loaded. When using `spriteSheet: true` this will not emit.
|
||||
* @event sl-error - Emitted when the icon fails to load due to an error. When using `spriteSheet: true` this will not emit.
|
||||
*
|
||||
* @csspart svg - The internal SVG element.
|
||||
* @csspart use - The <use> element generated when using `spriteSheet: true`
|
||||
*/
|
||||
@customElement('sl-icon')
|
||||
export default class SlIcon extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private initialRender = false;
|
||||
|
||||
/** Given a URL, this function returns the resulting SVG element or an appropriate error symbol. */
|
||||
private async resolveIcon(url: string, library?: IconLibrary): Promise<SVGResult> {
|
||||
let fileData: Response;
|
||||
|
||||
if (library?.spriteSheet) {
|
||||
return html`<svg part="svg">
|
||||
<use part="use" href="${url}"></use>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
try {
|
||||
fileData = await fetch(url, { mode: 'cors' });
|
||||
if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR;
|
||||
} catch {
|
||||
return RETRYABLE_ERROR;
|
||||
}
|
||||
|
||||
try {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = await fileData.text();
|
||||
|
||||
const svg = div.firstElementChild;
|
||||
if (svg?.tagName?.toLowerCase() !== 'svg') return CACHEABLE_ERROR;
|
||||
|
||||
if (!parser) parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svg.outerHTML, 'text/html');
|
||||
|
||||
const svgEl = doc.body.querySelector('svg');
|
||||
if (!svgEl) return CACHEABLE_ERROR;
|
||||
|
||||
svgEl.part.add('svg');
|
||||
return document.adoptNode(svgEl);
|
||||
} catch {
|
||||
return CACHEABLE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
@state() private svg: SVGElement | HTMLTemplateResult | null = null;
|
||||
|
||||
/** The name of the icon to draw. Available names depend on the icon library being used. */
|
||||
@property({ reflect: true }) name?: string;
|
||||
|
||||
/**
|
||||
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
|
||||
* can result in XSS attacks.
|
||||
*/
|
||||
@property() src?: string;
|
||||
|
||||
/**
|
||||
* An alternate description to use for assistive devices. If omitted, the icon will be considered presentational and
|
||||
* ignored by assistive devices.
|
||||
*/
|
||||
@property() label = '';
|
||||
|
||||
/** The name of a registered custom icon library. */
|
||||
@property({ reflect: true }) library = 'default';
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
watchIcon(this);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.initialRender = true;
|
||||
this.setIcon();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
unwatchIcon(this);
|
||||
}
|
||||
|
||||
private getUrl() {
|
||||
const library = getIconLibrary(this.library);
|
||||
if (this.name && library) {
|
||||
return library.resolver(this.name);
|
||||
}
|
||||
return this.src;
|
||||
}
|
||||
|
||||
@watch('label')
|
||||
handleLabelChange() {
|
||||
const hasLabel = typeof this.label === 'string' && this.label.length > 0;
|
||||
|
||||
if (hasLabel) {
|
||||
this.setAttribute('role', 'img');
|
||||
this.setAttribute('aria-label', this.label);
|
||||
this.removeAttribute('aria-hidden');
|
||||
} else {
|
||||
this.removeAttribute('role');
|
||||
this.removeAttribute('aria-label');
|
||||
this.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
@watch(['name', 'src', 'library'])
|
||||
async setIcon() {
|
||||
const library = getIconLibrary(this.library);
|
||||
const url = this.getUrl();
|
||||
|
||||
if (!url) {
|
||||
this.svg = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let iconResolver = iconCache.get(url);
|
||||
if (!iconResolver) {
|
||||
iconResolver = this.resolveIcon(url, library);
|
||||
iconCache.set(url, iconResolver);
|
||||
}
|
||||
|
||||
// If we haven't rendered yet, exit early. This avoids unnecessary work due to watching multiple props.
|
||||
if (!this.initialRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = await iconResolver;
|
||||
|
||||
if (svg === RETRYABLE_ERROR) {
|
||||
iconCache.delete(url);
|
||||
}
|
||||
|
||||
if (url !== this.getUrl()) {
|
||||
// If the url has changed while fetching the icon, ignore this request
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTemplateResult(svg)) {
|
||||
this.svg = svg;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (svg) {
|
||||
case RETRYABLE_ERROR:
|
||||
case CACHEABLE_ERROR:
|
||||
this.svg = null;
|
||||
this.emit('sl-error');
|
||||
break;
|
||||
default:
|
||||
this.svg = svg.cloneNode(true) as SVGElement;
|
||||
library?.mutator?.(this.svg);
|
||||
this.emit('sl-load');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.svg;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-icon': SlIcon;
|
||||
}
|
||||
}
|
||||
import SlIcon from './icon.component.js';
|
||||
export * from './icon.component.js';
|
||||
export default SlIcon;
|
||||
SlIcon.define('sl-icon');
|
||||
|
||||
159
src/components/image-comparer/image-comparer.component.ts
Normal file
159
src/components/image-comparer/image-comparer.component.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { drag } from '../../internal/drag.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './image-comparer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Compare visual differences between similar photos with a sliding panel.
|
||||
* @documentation https://shoelace.style/components/image-comparer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot before - The before image, an `<img>` or `<svg>` element.
|
||||
* @slot after - The after image, an `<img>` or `<svg>` element.
|
||||
* @slot handle - The icon used inside the handle.
|
||||
*
|
||||
* @event sl-change - Emitted when the position changes.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart before - The container that wraps the before image.
|
||||
* @csspart after - The container that wraps the after image.
|
||||
* @csspart divider - The divider that separates the images.
|
||||
* @csspart handle - The handle that the user drags to expose the after image.
|
||||
*
|
||||
* @cssproperty --divider-width - The width of the dividing line.
|
||||
* @cssproperty --handle-size - The size of the compare handle.
|
||||
*/
|
||||
export default class SlImageComparer extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static scopedElement = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.image-comparer') base: HTMLElement;
|
||||
@query('.image-comparer__handle') handle: HTMLElement;
|
||||
|
||||
/** The position of the divider as a percentage. */
|
||||
@property({ type: Number, reflect: true }) position = 50;
|
||||
|
||||
private handleDrag(event: PointerEvent) {
|
||||
const { width } = this.base.getBoundingClientRect();
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
drag(this.base, {
|
||||
onMove: x => {
|
||||
this.position = parseFloat(clamp((x / width) * 100, 0, 100).toFixed(2));
|
||||
if (isRtl) this.position = 100 - this.position;
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
||||
const incr = event.shiftKey ? 10 : 1;
|
||||
let newPosition = this.position;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if ((isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
|
||||
newPosition -= incr;
|
||||
}
|
||||
if ((isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
|
||||
newPosition += incr;
|
||||
}
|
||||
if (event.key === 'Home') {
|
||||
newPosition = 0;
|
||||
}
|
||||
if (event.key === 'End') {
|
||||
newPosition = 100;
|
||||
}
|
||||
newPosition = clamp(newPosition, 0, 100);
|
||||
|
||||
this.position = newPosition;
|
||||
}
|
||||
}
|
||||
|
||||
@watch('position', { waitUntilFirstUpdate: true })
|
||||
handlePositionChange() {
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
id="image-comparer"
|
||||
class=${classMap({
|
||||
'image-comparer': true,
|
||||
'image-comparer--rtl': isRtl
|
||||
})}
|
||||
@keydown=${this.handleKeyDown}
|
||||
>
|
||||
<div class="image-comparer__image">
|
||||
<div part="before" class="image-comparer__before">
|
||||
<slot name="before"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="after"
|
||||
class="image-comparer__after"
|
||||
style=${styleMap({
|
||||
clipPath: isRtl ? `inset(0 0 0 ${100 - this.position}%)` : `inset(0 ${100 - this.position}% 0 0)`
|
||||
})}
|
||||
>
|
||||
<slot name="after"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="divider"
|
||||
class="image-comparer__divider"
|
||||
style=${styleMap({
|
||||
left: isRtl ? `${100 - this.position}%` : `${this.position}%`
|
||||
})}
|
||||
@mousedown=${this.handleDrag}
|
||||
@touchstart=${this.handleDrag}
|
||||
>
|
||||
<div
|
||||
part="handle"
|
||||
class="image-comparer__handle"
|
||||
role="scrollbar"
|
||||
aria-valuenow=${this.position}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-controls="image-comparer"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot name="handle">
|
||||
<sl-icon library="system" name="grip-vertical"></sl-icon>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-image-comparer': SlImageComparer;
|
||||
}
|
||||
}
|
||||
@@ -1,155 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { drag } from '../../internal/drag.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './image-comparer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Compare visual differences between similar photos with a sliding panel.
|
||||
* @documentation https://shoelace.style/components/image-comparer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot before - The before image, an `<img>` or `<svg>` element.
|
||||
* @slot after - The after image, an `<img>` or `<svg>` element.
|
||||
* @slot handle - The icon used inside the handle.
|
||||
*
|
||||
* @event sl-change - Emitted when the position changes.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart before - The container that wraps the before image.
|
||||
* @csspart after - The container that wraps the after image.
|
||||
* @csspart divider - The divider that separates the images.
|
||||
* @csspart handle - The handle that the user drags to expose the after image.
|
||||
*
|
||||
* @cssproperty --divider-width - The width of the dividing line.
|
||||
* @cssproperty --handle-size - The size of the compare handle.
|
||||
*/
|
||||
@customElement('sl-image-comparer')
|
||||
export default class SlImageComparer extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.image-comparer') base: HTMLElement;
|
||||
@query('.image-comparer__handle') handle: HTMLElement;
|
||||
|
||||
/** The position of the divider as a percentage. */
|
||||
@property({ type: Number, reflect: true }) position = 50;
|
||||
|
||||
private handleDrag(event: PointerEvent) {
|
||||
const { width } = this.base.getBoundingClientRect();
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
drag(this.base, {
|
||||
onMove: x => {
|
||||
this.position = parseFloat(clamp((x / width) * 100, 0, 100).toFixed(2));
|
||||
if (isRtl) this.position = 100 - this.position;
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
||||
const incr = event.shiftKey ? 10 : 1;
|
||||
let newPosition = this.position;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if ((isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) {
|
||||
newPosition -= incr;
|
||||
}
|
||||
if ((isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) {
|
||||
newPosition += incr;
|
||||
}
|
||||
if (event.key === 'Home') {
|
||||
newPosition = 0;
|
||||
}
|
||||
if (event.key === 'End') {
|
||||
newPosition = 100;
|
||||
}
|
||||
newPosition = clamp(newPosition, 0, 100);
|
||||
|
||||
this.position = newPosition;
|
||||
}
|
||||
}
|
||||
|
||||
@watch('position', { waitUntilFirstUpdate: true })
|
||||
handlePositionChange() {
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
render() {
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
id="image-comparer"
|
||||
class=${classMap({
|
||||
'image-comparer': true,
|
||||
'image-comparer--rtl': isRtl
|
||||
})}
|
||||
@keydown=${this.handleKeyDown}
|
||||
>
|
||||
<div class="image-comparer__image">
|
||||
<slot name="before" part="before" class="image-comparer__before"></slot>
|
||||
|
||||
<slot
|
||||
name="after"
|
||||
part="after"
|
||||
class="image-comparer__after"
|
||||
style=${styleMap({
|
||||
clipPath: isRtl ? `inset(0 0 0 ${100 - this.position}%)` : `inset(0 ${100 - this.position}% 0 0)`
|
||||
})}
|
||||
></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="divider"
|
||||
class="image-comparer__divider"
|
||||
style=${styleMap({
|
||||
left: isRtl ? `${100 - this.position}%` : `${this.position}%`
|
||||
})}
|
||||
@mousedown=${this.handleDrag}
|
||||
@touchstart=${this.handleDrag}
|
||||
>
|
||||
<slot
|
||||
name="handle"
|
||||
part="handle"
|
||||
class="image-comparer__handle"
|
||||
role="scrollbar"
|
||||
aria-valuenow=${this.position}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-controls="image-comparer"
|
||||
tabindex="0"
|
||||
>
|
||||
<sl-icon library="system" name="grip-vertical"></sl-icon>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-image-comparer': SlImageComparer;
|
||||
}
|
||||
}
|
||||
import SlImageComparer from './image-comparer.component.js';
|
||||
export * from './image-comparer.component.js';
|
||||
export default SlImageComparer;
|
||||
SlImageComparer.define('sl-image-comparer');
|
||||
|
||||
81
src/components/include/include.component.ts
Normal file
81
src/components/include/include.component.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { requestInclude } from './request.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './include.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Includes give you the power to embed external HTML files into the page.
|
||||
* @documentation https://shoelace.style/components/include
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-load - Emitted when the included file is loaded.
|
||||
* @event {{ status: number }} sl-error - Emitted when the included file fails to load due to an error.
|
||||
*/
|
||||
export default class SlInclude extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/**
|
||||
* The location of the HTML file to include. Be sure you trust the content you are including as it will be executed as
|
||||
* code and can result in XSS attacks.
|
||||
*/
|
||||
@property() src: string;
|
||||
|
||||
/** The fetch mode to use. */
|
||||
@property() mode: 'cors' | 'no-cors' | 'same-origin' = 'cors';
|
||||
|
||||
/**
|
||||
* Allows included scripts to be executed. Be sure you trust the content you are including as it will be executed as
|
||||
* code and can result in XSS attacks.
|
||||
*/
|
||||
@property({ attribute: 'allow-scripts', type: Boolean }) allowScripts = false;
|
||||
|
||||
private executeScript(script: HTMLScriptElement) {
|
||||
// Create a copy of the script and swap it out so the browser executes it
|
||||
const newScript = document.createElement('script');
|
||||
[...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
||||
newScript.textContent = script.textContent;
|
||||
script.parentNode!.replaceChild(newScript, script);
|
||||
}
|
||||
|
||||
@watch('src')
|
||||
async handleSrcChange() {
|
||||
try {
|
||||
const src = this.src;
|
||||
const file = await requestInclude(src, this.mode);
|
||||
|
||||
// If the src changed since the request started do nothing, otherwise we risk overwriting a subsequent response
|
||||
if (src !== this.src) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.ok) {
|
||||
this.emit('sl-error', { detail: { status: file.status } });
|
||||
return;
|
||||
}
|
||||
|
||||
this.innerHTML = file.html;
|
||||
|
||||
if (this.allowScripts) {
|
||||
[...this.querySelectorAll('script')].forEach(script => this.executeScript(script));
|
||||
}
|
||||
|
||||
this.emit('sl-load');
|
||||
} catch {
|
||||
this.emit('sl-error', { detail: { status: -1 } });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-include': SlInclude;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { requestInclude } from './request.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './include.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Includes give you the power to embed external HTML files into the page.
|
||||
* @documentation https://shoelace.style/components/include
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-load - Emitted when the included file is loaded.
|
||||
* @event {{ status: number }} sl-error - Emitted when the included file fails to load due to an error.
|
||||
*/
|
||||
@customElement('sl-include')
|
||||
export default class SlInclude extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
/**
|
||||
* The location of the HTML file to include. Be sure you trust the content you are including as it will be executed as
|
||||
* code and can result in XSS attacks.
|
||||
*/
|
||||
@property() src: string;
|
||||
|
||||
/** The fetch mode to use. */
|
||||
@property() mode: 'cors' | 'no-cors' | 'same-origin' = 'cors';
|
||||
|
||||
/**
|
||||
* Allows included scripts to be executed. Be sure you trust the content you are including as it will be executed as
|
||||
* code and can result in XSS attacks.
|
||||
*/
|
||||
@property({ attribute: 'allow-scripts', type: Boolean }) allowScripts = false;
|
||||
|
||||
private executeScript(script: HTMLScriptElement) {
|
||||
// Create a copy of the script and swap it out so the browser executes it
|
||||
const newScript = document.createElement('script');
|
||||
[...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
||||
newScript.textContent = script.textContent;
|
||||
script.parentNode!.replaceChild(newScript, script);
|
||||
}
|
||||
|
||||
@watch('src')
|
||||
async handleSrcChange() {
|
||||
try {
|
||||
const src = this.src;
|
||||
const file = await requestInclude(src, this.mode);
|
||||
|
||||
// If the src changed since the request started do nothing, otherwise we risk overwriting a subsequent response
|
||||
if (src !== this.src) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.ok) {
|
||||
this.emit('sl-error', { detail: { status: file.status } });
|
||||
return;
|
||||
}
|
||||
|
||||
this.innerHTML = file.html;
|
||||
|
||||
if (this.allowScripts) {
|
||||
[...this.querySelectorAll('script')].forEach(script => this.executeScript(script));
|
||||
}
|
||||
|
||||
this.emit('sl-load');
|
||||
} catch {
|
||||
this.emit('sl-error', { detail: { status: -1 } });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-include': SlInclude;
|
||||
}
|
||||
}
|
||||
import SlInclude from './include.component.js';
|
||||
export * from './include.component.js';
|
||||
export default SlInclude;
|
||||
SlInclude.define('sl-include');
|
||||
|
||||
556
src/components/input/input.component.ts
Normal file
556
src/components/input/input.component.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './input.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Inputs collect data from the user.
|
||||
* @documentation https://shoelace.style/components/input
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot prefix - Used to prepend a presentational icon or similar element to the input.
|
||||
* @slot suffix - Used to append a presentational icon or similar element to the input.
|
||||
* @slot clear-icon - An icon to use in lieu of the default clear icon.
|
||||
* @slot show-password-icon - An icon to use in lieu of the default show password icon.
|
||||
* @slot hide-password-icon - An icon to use in lieu of the default hide password icon.
|
||||
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
||||
*
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
|
||||
* @event sl-clear - Emitted when the clear button is activated.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The input's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart input - The internal `<input>` control.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart clear-button - The clear button.
|
||||
* @csspart password-toggle-button - The password toggle button.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.input__control') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
private __numberInput = Object.assign(document.createElement('input'), { type: 'number' });
|
||||
private __dateInput = Object.assign(document.createElement('input'), { type: 'date' });
|
||||
|
||||
/**
|
||||
* The type of input. Works the same as a native `<input>` element, but only a subset of types are supported. Defaults
|
||||
* to `text`.
|
||||
*/
|
||||
@property({ reflect: true }) type:
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
| 'email'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'search'
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'time'
|
||||
| 'url' = 'text';
|
||||
|
||||
/** The name of the input, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the input, submitted as a name/value pair with form data. */
|
||||
@property() value = '';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue = '';
|
||||
|
||||
/** The input's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws a filled input. */
|
||||
@property({ type: Boolean, reflect: true }) filled = false;
|
||||
|
||||
/** Draws a pill-style input with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** The input's label. If you need to display HTML, use the `label` slot instead. */
|
||||
@property() label = '';
|
||||
|
||||
/** The input's help text. If you need to display HTML, use the `help-text` slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/** Adds a clear button when the input is not empty. */
|
||||
@property({ type: Boolean }) clearable = false;
|
||||
|
||||
/** Disables the input. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Placeholder text to show as a hint when the input is empty. */
|
||||
@property() placeholder = '';
|
||||
|
||||
/** Makes the input readonly. */
|
||||
@property({ type: Boolean, reflect: true }) readonly = false;
|
||||
|
||||
/** Adds a button to toggle the password's visibility. Only applies to password types. */
|
||||
@property({ attribute: 'password-toggle', type: Boolean }) passwordToggle = false;
|
||||
|
||||
/** Determines whether or not the password is currently visible. Only applies to password input types. */
|
||||
@property({ attribute: 'password-visible', type: Boolean }) passwordVisible = false;
|
||||
|
||||
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
|
||||
@property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = false;
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Makes the input a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** A regular expression pattern to validate input against. */
|
||||
@property() pattern: string;
|
||||
|
||||
/** The minimum length of input that will be considered valid. */
|
||||
@property({ type: Number }) minlength: number;
|
||||
|
||||
/** The maximum length of input that will be considered valid. */
|
||||
@property({ type: Number }) maxlength: number;
|
||||
|
||||
/** The input's minimum value. Only applies to date and number input types. */
|
||||
@property() min: number | string;
|
||||
|
||||
/** The input's maximum value. Only applies to date and number input types. */
|
||||
@property() max: number | string;
|
||||
|
||||
/**
|
||||
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
|
||||
* implied, allowing any numeric value. Only applies to date and number input types.
|
||||
*/
|
||||
@property() step: number | 'any';
|
||||
|
||||
/** Controls whether and how text input is automatically capitalized as it is entered by the user. */
|
||||
@property() autocapitalize: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters';
|
||||
|
||||
/** Indicates whether the browser's autocorrect feature is on or off. */
|
||||
@property() autocorrect: 'off' | 'on';
|
||||
|
||||
/**
|
||||
* Specifies what permission the browser has to provide assistance in filling out form field values. Refer to
|
||||
* [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values.
|
||||
*/
|
||||
@property() autocomplete: string;
|
||||
|
||||
/** Indicates that the input should receive focus on page load. */
|
||||
@property({ type: Boolean }) autofocus: boolean;
|
||||
|
||||
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
|
||||
@property() enterkeyhint: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
|
||||
|
||||
/** Enables spell checking on the input. */
|
||||
@property({
|
||||
type: Boolean,
|
||||
converter: {
|
||||
// Allow "true|false" attribute values but keep the property boolean
|
||||
fromAttribute: value => (!value || value === 'false' ? false : true),
|
||||
toAttribute: value => (value ? 'true' : 'false')
|
||||
}
|
||||
})
|
||||
spellcheck = true;
|
||||
|
||||
/**
|
||||
* Tells the browser what type of data will be entered by the user, allowing it to display the appropriate virtual
|
||||
* keyboard on supportive devices.
|
||||
*/
|
||||
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
||||
|
||||
//
|
||||
// NOTE: We use an in-memory input for these getters/setters instead of the one in the template because the properties
|
||||
// can be set before the component is rendered.
|
||||
//
|
||||
|
||||
/**
|
||||
* Gets or sets the current value as a `Date` object. Returns `null` if the value can't be converted. This will use the native `<input type="{{type}}">` implementation and may result in an error.
|
||||
*/
|
||||
get valueAsDate() {
|
||||
this.__dateInput.type = this.type;
|
||||
this.__dateInput.value = this.value;
|
||||
return this.input?.valueAsDate || this.__dateInput.valueAsDate;
|
||||
}
|
||||
|
||||
set valueAsDate(newValue: Date | null) {
|
||||
this.__dateInput.type = this.type;
|
||||
this.__dateInput.valueAsDate = newValue;
|
||||
this.value = this.__dateInput.value;
|
||||
}
|
||||
|
||||
/** Gets or sets the current value as a number. Returns `NaN` if the value can't be converted. */
|
||||
get valueAsNumber() {
|
||||
this.__numberInput.value = this.value;
|
||||
return this.input?.valueAsNumber || this.__numberInput.valueAsNumber;
|
||||
}
|
||||
|
||||
set valueAsNumber(newValue: number) {
|
||||
this.__numberInput.valueAsNumber = newValue;
|
||||
this.value = this.__numberInput.value;
|
||||
}
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleChange() {
|
||||
this.value = this.input.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleClearClick(event: MouseEvent) {
|
||||
this.value = '';
|
||||
this.emit('sl-clear');
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
this.input.focus();
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.value = this.input.value;
|
||||
this.formControlController.updateValidity();
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||
|
||||
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
|
||||
// submitting to allow users to cancel the keydown event if they need to
|
||||
if (event.key === 'Enter' && !hasModifier) {
|
||||
setTimeout(() => {
|
||||
//
|
||||
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
|
||||
// to check for this is to look at event.isComposing, which will be true when the IME is open.
|
||||
//
|
||||
// See https://github.com/shoelace-style/shoelace/pull/988
|
||||
//
|
||||
if (!event.defaultPrevented && !event.isComposing) {
|
||||
this.formControlController.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handlePasswordToggle() {
|
||||
this.passwordVisible = !this.passwordVisible;
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch('step', { waitUntilFirstUpdate: true })
|
||||
handleStepChange() {
|
||||
// If step changes, the value may become invalid so we need to recheck after the update. We set the new step
|
||||
// imperatively so we don't have to wait for the next render to report the updated validity.
|
||||
this.input.step = String(this.step);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
@watch('value', { waitUntilFirstUpdate: true })
|
||||
async handleValueChange() {
|
||||
await this.updateComplete;
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Sets focus on the input. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the input. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Selects all the text in the input. */
|
||||
select() {
|
||||
this.input.select();
|
||||
}
|
||||
|
||||
/** Sets the start and end positions of the text selection (0-based). */
|
||||
setSelectionRange(
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
selectionDirection: 'forward' | 'backward' | 'none' = 'none'
|
||||
) {
|
||||
this.input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
|
||||
}
|
||||
|
||||
/** Replaces a range of text with a new string. */
|
||||
setRangeText(
|
||||
replacement: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
selectMode?: 'select' | 'start' | 'end' | 'preserve'
|
||||
) {
|
||||
// @ts-expect-error - start, end, and selectMode are optional
|
||||
this.input.setRangeText(replacement, start, end, selectMode);
|
||||
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Displays the browser picker for an input element (only works if the browser supports it for the input type). */
|
||||
showPicker() {
|
||||
if ('showPicker' in HTMLInputElement.prototype) {
|
||||
this.input.showPicker();
|
||||
}
|
||||
}
|
||||
|
||||
/** Increments the value of a numeric input type by the value of the step attribute. */
|
||||
stepUp() {
|
||||
this.input.stepUp();
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrements the value of a numeric input type by the value of the step attribute. */
|
||||
stepDown() {
|
||||
this.input.stepDown();
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
const hasClearIcon =
|
||||
this.clearable && !this.disabled && !this.readonly && (typeof this.value === 'number' || this.value.length > 0);
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--small': this.size === 'small',
|
||||
'form-control--medium': this.size === 'medium',
|
||||
'form-control--large': this.size === 'large',
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<label
|
||||
part="form-control-label"
|
||||
class="form-control__label"
|
||||
for="input"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
input: true,
|
||||
|
||||
// Sizes
|
||||
'input--small': this.size === 'small',
|
||||
'input--medium': this.size === 'medium',
|
||||
'input--large': this.size === 'large',
|
||||
|
||||
// States
|
||||
'input--pill': this.pill,
|
||||
'input--standard': !this.filled,
|
||||
'input--filled': this.filled,
|
||||
'input--disabled': this.disabled,
|
||||
'input--focused': this.hasFocus,
|
||||
'input--empty': !this.value,
|
||||
'input--no-spin-buttons': this.noSpinButtons
|
||||
})}
|
||||
>
|
||||
<span part="prefix" class="input__prefix">
|
||||
<slot name="prefix"></slot>
|
||||
</span>
|
||||
|
||||
<input
|
||||
part="input"
|
||||
id="input"
|
||||
class="input__control"
|
||||
type=${this.type === 'password' && this.passwordVisible ? 'text' : this.type}
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${ifDefined(this.name)}
|
||||
?disabled=${this.disabled}
|
||||
?readonly=${this.readonly}
|
||||
?required=${this.required}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
minlength=${ifDefined(this.minlength)}
|
||||
maxlength=${ifDefined(this.maxlength)}
|
||||
min=${ifDefined(this.min)}
|
||||
max=${ifDefined(this.max)}
|
||||
step=${ifDefined(this.step as number)}
|
||||
.value=${live(this.value)}
|
||||
autocapitalize=${ifDefined(this.autocapitalize)}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
autocorrect=${ifDefined(this.autocorrect)}
|
||||
?autofocus=${this.autofocus}
|
||||
spellcheck=${this.spellcheck}
|
||||
pattern=${ifDefined(this.pattern)}
|
||||
enterkeyhint=${ifDefined(this.enterkeyhint)}
|
||||
inputmode=${ifDefined(this.inputmode)}
|
||||
aria-describedby="help-text"
|
||||
@change=${this.handleChange}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
|
||||
${hasClearIcon
|
||||
? html`
|
||||
<button
|
||||
part="clear-button"
|
||||
class="input__clear"
|
||||
type="button"
|
||||
aria-label=${this.localize.term('clearEntry')}
|
||||
@click=${this.handleClearClick}
|
||||
tabindex="-1"
|
||||
>
|
||||
<slot name="clear-icon">
|
||||
<sl-icon name="x-circle-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
${this.passwordToggle && !this.disabled
|
||||
? html`
|
||||
<button
|
||||
part="password-toggle-button"
|
||||
class="input__password-toggle"
|
||||
type="button"
|
||||
aria-label=${this.localize.term(this.passwordVisible ? 'hidePassword' : 'showPassword')}
|
||||
@click=${this.handlePasswordToggle}
|
||||
tabindex="-1"
|
||||
>
|
||||
${this.passwordVisible
|
||||
? html`
|
||||
<slot name="show-password-icon">
|
||||
<sl-icon name="eye-slash" library="system"></sl-icon>
|
||||
</slot>
|
||||
`
|
||||
: html`
|
||||
<slot name="hide-password-icon">
|
||||
<sl-icon name="eye" library="system"></sl-icon>
|
||||
</slot>
|
||||
`}
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<span part="suffix" class="input__suffix">
|
||||
<slot name="suffix"></slot>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="help-text">${this.helpText}</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-input': SlInput;
|
||||
}
|
||||
}
|
||||
@@ -147,8 +147,8 @@ export default css`
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.input__prefix::slotted(sl-icon),
|
||||
.input__suffix::slotted(sl-icon) {
|
||||
.input__prefix ::slotted(sl-icon),
|
||||
.input__suffix ::slotted(sl-icon) {
|
||||
color: var(--sl-input-icon-color);
|
||||
}
|
||||
|
||||
@@ -172,11 +172,11 @@ export default css`
|
||||
width: calc(1em + var(--sl-input-spacing-small) * 2);
|
||||
}
|
||||
|
||||
.input--small .input__prefix::slotted(*) {
|
||||
.input--small .input__prefix ::slotted(*) {
|
||||
margin-inline-start: var(--sl-input-spacing-small);
|
||||
}
|
||||
|
||||
.input--small .input__suffix::slotted(*) {
|
||||
.input--small .input__suffix ::slotted(*) {
|
||||
margin-inline-end: var(--sl-input-spacing-small);
|
||||
}
|
||||
|
||||
@@ -196,11 +196,11 @@ export default css`
|
||||
width: calc(1em + var(--sl-input-spacing-medium) * 2);
|
||||
}
|
||||
|
||||
.input--medium .input__prefix::slotted(*) {
|
||||
.input--medium .input__prefix ::slotted(*) {
|
||||
margin-inline-start: var(--sl-input-spacing-medium);
|
||||
}
|
||||
|
||||
.input--medium .input__suffix::slotted(*) {
|
||||
.input--medium .input__suffix ::slotted(*) {
|
||||
margin-inline-end: var(--sl-input-spacing-medium);
|
||||
}
|
||||
|
||||
@@ -220,11 +220,11 @@ export default css`
|
||||
width: calc(1em + var(--sl-input-spacing-large) * 2);
|
||||
}
|
||||
|
||||
.input--large .input__prefix::slotted(*) {
|
||||
.input--large .input__prefix ::slotted(*) {
|
||||
margin-inline-start: var(--sl-input-spacing-large);
|
||||
}
|
||||
|
||||
.input--large .input__suffix::slotted(*) {
|
||||
.input--large .input__suffix ::slotted(*) {
|
||||
margin-inline-end: var(--sl-input-spacing-large);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,553 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { defaultValue } from '../../internal/default-value.js';
|
||||
import { FormControlController } from '../../internal/form.js';
|
||||
import { HasSlotController } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './input.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
|
||||
|
||||
/**
|
||||
* @summary Inputs collect data from the user.
|
||||
* @documentation https://shoelace.style/components/input
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
|
||||
* @slot prefix - Used to prepend a presentational icon or similar element to the input.
|
||||
* @slot suffix - Used to append a presentational icon or similar element to the input.
|
||||
* @slot clear-icon - An icon to use in lieu of the default clear icon.
|
||||
* @slot show-password-icon - An icon to use in lieu of the default show password icon.
|
||||
* @slot hide-password-icon - An icon to use in lieu of the default hide password icon.
|
||||
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
||||
*
|
||||
* @event sl-blur - Emitted when the control loses focus.
|
||||
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
|
||||
* @event sl-clear - Emitted when the clear button is activated.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The input's wrapper.
|
||||
* @csspart form-control-help-text - The help text's wrapper.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart input - The internal `<input>` control.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart clear-button - The clear button.
|
||||
* @csspart password-toggle-button - The password toggle button.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
@customElement('sl-input')
|
||||
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.input__control') input: HTMLInputElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@property() title = ''; // make reactive to pass through
|
||||
|
||||
private __numberInput = Object.assign(document.createElement('input'), { type: 'number' });
|
||||
private __dateInput = Object.assign(document.createElement('input'), { type: 'date' });
|
||||
|
||||
/**
|
||||
* The type of input. Works the same as a native `<input>` element, but only a subset of types are supported. Defaults
|
||||
* to `text`.
|
||||
*/
|
||||
@property({ reflect: true }) type:
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
| 'email'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'search'
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'time'
|
||||
| 'url' = 'text';
|
||||
|
||||
/** The name of the input, submitted as a name/value pair with form data. */
|
||||
@property() name = '';
|
||||
|
||||
/** The current value of the input, submitted as a name/value pair with form data. */
|
||||
@property() value = '';
|
||||
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue = '';
|
||||
|
||||
/** The input's size. */
|
||||
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
||||
|
||||
/** Draws a filled input. */
|
||||
@property({ type: Boolean, reflect: true }) filled = false;
|
||||
|
||||
/** Draws a pill-style input with rounded edges. */
|
||||
@property({ type: Boolean, reflect: true }) pill = false;
|
||||
|
||||
/** The input's label. If you need to display HTML, use the `label` slot instead. */
|
||||
@property() label = '';
|
||||
|
||||
/** The input's help text. If you need to display HTML, use the `help-text` slot instead. */
|
||||
@property({ attribute: 'help-text' }) helpText = '';
|
||||
|
||||
/** Adds a clear button when the input is not empty. */
|
||||
@property({ type: Boolean }) clearable = false;
|
||||
|
||||
/** Disables the input. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
/** Placeholder text to show as a hint when the input is empty. */
|
||||
@property() placeholder = '';
|
||||
|
||||
/** Makes the input readonly. */
|
||||
@property({ type: Boolean, reflect: true }) readonly = false;
|
||||
|
||||
/** Adds a button to toggle the password's visibility. Only applies to password types. */
|
||||
@property({ attribute: 'password-toggle', type: Boolean }) passwordToggle = false;
|
||||
|
||||
/** Determines whether or not the password is currently visible. Only applies to password input types. */
|
||||
@property({ attribute: 'password-visible', type: Boolean }) passwordVisible = false;
|
||||
|
||||
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
|
||||
@property({ attribute: 'no-spin-buttons', type: Boolean }) noSpinButtons = false;
|
||||
|
||||
/**
|
||||
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
|
||||
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
|
||||
* the same document or shadow root for this to work.
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Makes the input a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** A regular expression pattern to validate input against. */
|
||||
@property() pattern: string;
|
||||
|
||||
/** The minimum length of input that will be considered valid. */
|
||||
@property({ type: Number }) minlength: number;
|
||||
|
||||
/** The maximum length of input that will be considered valid. */
|
||||
@property({ type: Number }) maxlength: number;
|
||||
|
||||
/** The input's minimum value. Only applies to date and number input types. */
|
||||
@property() min: number | string;
|
||||
|
||||
/** The input's maximum value. Only applies to date and number input types. */
|
||||
@property() max: number | string;
|
||||
|
||||
/**
|
||||
* Specifies the granularity that the value must adhere to, or the special value `any` which means no stepping is
|
||||
* implied, allowing any numeric value. Only applies to date and number input types.
|
||||
*/
|
||||
@property() step: number | 'any';
|
||||
|
||||
/** Controls whether and how text input is automatically capitalized as it is entered by the user. */
|
||||
@property() autocapitalize: 'off' | 'none' | 'on' | 'sentences' | 'words' | 'characters';
|
||||
|
||||
/** Indicates whether the browser's autocorrect feature is on or off. */
|
||||
@property() autocorrect: 'off' | 'on';
|
||||
|
||||
/**
|
||||
* Specifies what permission the browser has to provide assistance in filling out form field values. Refer to
|
||||
* [this page on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for available values.
|
||||
*/
|
||||
@property() autocomplete: string;
|
||||
|
||||
/** Indicates that the input should receive focus on page load. */
|
||||
@property({ type: Boolean }) autofocus: boolean;
|
||||
|
||||
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
|
||||
@property() enterkeyhint: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
|
||||
|
||||
/** Enables spell checking on the input. */
|
||||
@property({
|
||||
type: Boolean,
|
||||
converter: {
|
||||
// Allow "true|false" attribute values but keep the property boolean
|
||||
fromAttribute: value => (!value || value === 'false' ? false : true),
|
||||
toAttribute: value => (value ? 'true' : 'false')
|
||||
}
|
||||
})
|
||||
spellcheck = true;
|
||||
|
||||
/**
|
||||
* Tells the browser what type of data will be entered by the user, allowing it to display the appropriate virtual
|
||||
* keyboard on supportive devices.
|
||||
*/
|
||||
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
||||
|
||||
//
|
||||
// NOTE: We use an in-memory input for these getters/setters instead of the one in the template because the properties
|
||||
// can be set before the component is rendered.
|
||||
//
|
||||
|
||||
/** Gets or sets the current value as a `Date` object. Returns `null` if the value can't be converted. */
|
||||
get valueAsDate() {
|
||||
this.__dateInput.value = this.value;
|
||||
return this.input?.valueAsDate || this.__dateInput.valueAsDate;
|
||||
}
|
||||
|
||||
set valueAsDate(newValue: Date | null) {
|
||||
this.__dateInput.valueAsDate = newValue;
|
||||
this.value = this.__dateInput.value;
|
||||
}
|
||||
|
||||
/** Gets or sets the current value as a number. Returns `NaN` if the value can't be converted. */
|
||||
get valueAsNumber() {
|
||||
this.__numberInput.value = this.value;
|
||||
return this.input?.valueAsNumber || this.__numberInput.valueAsNumber;
|
||||
}
|
||||
|
||||
set valueAsNumber(newValue: number) {
|
||||
this.__numberInput.valueAsNumber = newValue;
|
||||
this.value = this.__numberInput.value;
|
||||
}
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleChange() {
|
||||
this.value = this.input.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
|
||||
private handleClearClick(event: MouseEvent) {
|
||||
this.value = '';
|
||||
this.emit('sl-clear');
|
||||
this.emit('sl-input');
|
||||
this.emit('sl-change');
|
||||
this.input.focus();
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleInput() {
|
||||
this.value = this.input.value;
|
||||
this.formControlController.updateValidity();
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||
|
||||
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
|
||||
// submitting to allow users to cancel the keydown event if they need to
|
||||
if (event.key === 'Enter' && !hasModifier) {
|
||||
setTimeout(() => {
|
||||
//
|
||||
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
|
||||
// to check for this is to look at event.isComposing, which will be true when the IME is open.
|
||||
//
|
||||
// See https://github.com/shoelace-style/shoelace/pull/988
|
||||
//
|
||||
if (!event.defaultPrevented && !event.isComposing) {
|
||||
this.formControlController.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handlePasswordToggle() {
|
||||
this.passwordVisible = !this.passwordVisible;
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Disabled form controls are always valid
|
||||
this.formControlController.setValidity(this.disabled);
|
||||
}
|
||||
|
||||
@watch('step', { waitUntilFirstUpdate: true })
|
||||
handleStepChange() {
|
||||
// If step changes, the value may become invalid so we need to recheck after the update. We set the new step
|
||||
// imperatively so we don't have to wait for the next render to report the updated validity.
|
||||
this.input.step = String(this.step);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
@watch('value', { waitUntilFirstUpdate: true })
|
||||
async handleValueChange() {
|
||||
await this.updateComplete;
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
/** Sets focus on the input. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
}
|
||||
|
||||
/** Removes focus from the input. */
|
||||
blur() {
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Selects all the text in the input. */
|
||||
select() {
|
||||
this.input.select();
|
||||
}
|
||||
|
||||
/** Sets the start and end positions of the text selection (0-based). */
|
||||
setSelectionRange(
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
selectionDirection: 'forward' | 'backward' | 'none' = 'none'
|
||||
) {
|
||||
this.input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
|
||||
}
|
||||
|
||||
/** Replaces a range of text with a new string. */
|
||||
setRangeText(
|
||||
replacement: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
selectMode?: 'select' | 'start' | 'end' | 'preserve'
|
||||
) {
|
||||
// @ts-expect-error - start, end, and selectMode are optional
|
||||
this.input.setRangeText(replacement, start, end, selectMode);
|
||||
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Displays the browser picker for an input element (only works if the browser supports it for the input type). */
|
||||
showPicker() {
|
||||
if ('showPicker' in HTMLInputElement.prototype) {
|
||||
this.input.showPicker();
|
||||
}
|
||||
}
|
||||
|
||||
/** Increments the value of a numeric input type by the value of the step attribute. */
|
||||
stepUp() {
|
||||
this.input.stepUp();
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrements the value of a numeric input type by the value of the step attribute. */
|
||||
stepDown() {
|
||||
this.input.stepDown();
|
||||
if (this.value !== this.input.value) {
|
||||
this.value = this.input.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Gets the associated form, if one exists. */
|
||||
getForm(): HTMLFormElement | null {
|
||||
return this.formControlController.getForm();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
|
||||
/** Sets a custom validation message. Pass an empty string to restore validity. */
|
||||
setCustomValidity(message: string) {
|
||||
this.input.setCustomValidity(message);
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasLabelSlot = this.hasSlotController.test('label');
|
||||
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
||||
const hasLabel = this.label ? true : !!hasLabelSlot;
|
||||
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
|
||||
const hasClearIcon =
|
||||
this.clearable && !this.disabled && !this.readonly && (typeof this.value === 'number' || this.value.length > 0);
|
||||
|
||||
return html`
|
||||
<div
|
||||
part="form-control"
|
||||
class=${classMap({
|
||||
'form-control': true,
|
||||
'form-control--small': this.size === 'small',
|
||||
'form-control--medium': this.size === 'medium',
|
||||
'form-control--large': this.size === 'large',
|
||||
'form-control--has-label': hasLabel,
|
||||
'form-control--has-help-text': hasHelpText
|
||||
})}
|
||||
>
|
||||
<label
|
||||
part="form-control-label"
|
||||
class="form-control__label"
|
||||
for="input"
|
||||
aria-hidden=${hasLabel ? 'false' : 'true'}
|
||||
>
|
||||
<slot name="label">${this.label}</slot>
|
||||
</label>
|
||||
|
||||
<div part="form-control-input" class="form-control-input">
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
input: true,
|
||||
|
||||
// Sizes
|
||||
'input--small': this.size === 'small',
|
||||
'input--medium': this.size === 'medium',
|
||||
'input--large': this.size === 'large',
|
||||
|
||||
// States
|
||||
'input--pill': this.pill,
|
||||
'input--standard': !this.filled,
|
||||
'input--filled': this.filled,
|
||||
'input--disabled': this.disabled,
|
||||
'input--focused': this.hasFocus,
|
||||
'input--empty': !this.value,
|
||||
'input--no-spin-buttons': this.noSpinButtons
|
||||
})}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="input__prefix"></slot>
|
||||
<input
|
||||
part="input"
|
||||
id="input"
|
||||
class="input__control"
|
||||
type=${this.type === 'password' && this.passwordVisible ? 'text' : this.type}
|
||||
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
|
||||
name=${ifDefined(this.name)}
|
||||
?disabled=${this.disabled}
|
||||
?readonly=${this.readonly}
|
||||
?required=${this.required}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
minlength=${ifDefined(this.minlength)}
|
||||
maxlength=${ifDefined(this.maxlength)}
|
||||
min=${ifDefined(this.min)}
|
||||
max=${ifDefined(this.max)}
|
||||
step=${ifDefined(this.step as number)}
|
||||
.value=${live(this.value)}
|
||||
autocapitalize=${ifDefined(this.autocapitalize)}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
autocorrect=${ifDefined(this.autocorrect)}
|
||||
?autofocus=${this.autofocus}
|
||||
spellcheck=${this.spellcheck}
|
||||
pattern=${ifDefined(this.pattern)}
|
||||
enterkeyhint=${ifDefined(this.enterkeyhint)}
|
||||
inputmode=${ifDefined(this.inputmode)}
|
||||
aria-describedby="help-text"
|
||||
@change=${this.handleChange}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
|
||||
${
|
||||
hasClearIcon
|
||||
? html`
|
||||
<button
|
||||
part="clear-button"
|
||||
class="input__clear"
|
||||
type="button"
|
||||
aria-label=${this.localize.term('clearEntry')}
|
||||
@click=${this.handleClearClick}
|
||||
tabindex="-1"
|
||||
>
|
||||
<slot name="clear-icon">
|
||||
<sl-icon name="x-circle-fill" library="system"></sl-icon>
|
||||
</slot>
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
this.passwordToggle && !this.disabled
|
||||
? html`
|
||||
<button
|
||||
part="password-toggle-button"
|
||||
class="input__password-toggle"
|
||||
type="button"
|
||||
aria-label=${this.localize.term(this.passwordVisible ? 'hidePassword' : 'showPassword')}
|
||||
@click=${this.handlePasswordToggle}
|
||||
tabindex="-1"
|
||||
>
|
||||
${this.passwordVisible
|
||||
? html`
|
||||
<slot name="show-password-icon">
|
||||
<sl-icon name="eye-slash" library="system"></sl-icon>
|
||||
</slot>
|
||||
`
|
||||
: html`
|
||||
<slot name="hide-password-icon">
|
||||
<sl-icon name="eye" library="system"></sl-icon>
|
||||
</slot>
|
||||
`}
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<slot name="suffix" part="suffix" class="input__suffix"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
name="help-text"
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
${this.helpText}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-input': SlInput;
|
||||
}
|
||||
}
|
||||
import SlInput from './input.component.js';
|
||||
export * from './input.component.js';
|
||||
export default SlInput;
|
||||
SlInput.define('sl-input');
|
||||
|
||||
138
src/components/menu-item/menu-item.component.ts
Normal file
138
src/components/menu-item/menu-item.component.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { getTextContent } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './menu-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu items provide options for the user to pick from in a menu.
|
||||
* @documentation https://shoelace.style/components/menu-item
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The menu item's label.
|
||||
* @slot prefix - Used to prepend an icon or similar element to the menu item.
|
||||
* @slot suffix - Used to append an icon or similar element to the menu item.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart checked-icon - The checked icon, which is only visible when the menu item is checked.
|
||||
* @csspart prefix - The prefix container.
|
||||
* @csspart label - The menu item label.
|
||||
* @csspart suffix - The suffix container.
|
||||
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
|
||||
*/
|
||||
export default class SlMenuItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private cachedTextLabel: string;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.menu-item') menuItem: HTMLElement;
|
||||
|
||||
/** The type of menu item to render. To use `checked`, this value must be set to `checkbox`. */
|
||||
@property() type: 'normal' | 'checkbox' = 'normal';
|
||||
|
||||
/** Draws the item in a checked state. */
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */
|
||||
@property() value = '';
|
||||
|
||||
/** Draws the menu item in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
// Ignore the first time the label is set
|
||||
if (typeof this.cachedTextLabel === 'undefined') {
|
||||
this.cachedTextLabel = textLabel;
|
||||
return;
|
||||
}
|
||||
|
||||
// When the label changes, emit a slotchange event so parent controls see it
|
||||
if (textLabel !== this.cachedTextLabel) {
|
||||
this.cachedTextLabel = textLabel;
|
||||
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
|
||||
}
|
||||
}
|
||||
|
||||
@watch('checked')
|
||||
handleCheckedChange() {
|
||||
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
|
||||
if (this.checked && this.type !== 'checkbox') {
|
||||
this.checked = false;
|
||||
console.error('The checked attribute can only be used on menu items with type="checkbox"', this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only checkbox types can receive the aria-checked attribute
|
||||
if (this.type === 'checkbox') {
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
} else {
|
||||
this.removeAttribute('aria-checked');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('type')
|
||||
handleTypeChange() {
|
||||
if (this.type === 'checkbox') {
|
||||
this.setAttribute('role', 'menuitemcheckbox');
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
} else {
|
||||
this.setAttribute('role', 'menuitem');
|
||||
this.removeAttribute('aria-checked');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a text label based on the contents of the menu item's default slot. */
|
||||
getTextLabel() {
|
||||
return getTextContent(this.defaultSlot);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'menu-item': true,
|
||||
'menu-item--checked': this.checked,
|
||||
'menu-item--disabled': this.disabled,
|
||||
'menu-item--has-submenu': false // reserved for future use
|
||||
})}
|
||||
>
|
||||
<span part="checked-icon" class="menu-item__check">
|
||||
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
|
||||
</span>
|
||||
|
||||
<slot name="prefix" part="prefix" class="menu-item__prefix"></slot>
|
||||
|
||||
<slot part="label" class="menu-item__label" @slotchange=${this.handleDefaultSlotChange}></slot>
|
||||
|
||||
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
|
||||
|
||||
<span part="submenu-icon" class="menu-item__chevron">
|
||||
<sl-icon name="chevron-right" library="system" aria-hidden="true"></sl-icon>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu-item': SlMenuItem;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import '../../../dist/shoelace.js';
|
||||
import { clickOnElement } from '../../internal/test.js';
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
import type SlMenuItem from './menu-item';
|
||||
@@ -32,16 +31,6 @@ describe('<sl-menu-item>', () => {
|
||||
expect(el.getAttribute('aria-disabled')).to.equal('true');
|
||||
});
|
||||
|
||||
it('should not emit the click event when disabled', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item disabled>Test</sl-menu-item> `);
|
||||
const clickHandler = sinon.spy();
|
||||
el.addEventListener('click', clickHandler);
|
||||
await clickOnElement(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(clickHandler).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('should return a text label when calling getTextLabel()', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
|
||||
expect(el.getTextLabel()).to.equal('Test');
|
||||
|
||||
@@ -1,151 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { getTextContent } from '../../internal/slot.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu-item.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu items provide options for the user to pick from in a menu.
|
||||
* @documentation https://shoelace.style/components/menu-item
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The menu item's label.
|
||||
* @slot prefix - Used to prepend an icon or similar element to the menu item.
|
||||
* @slot suffix - Used to append an icon or similar element to the menu item.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart checked-icon - The checked icon, which is only visible when the menu item is checked.
|
||||
* @csspart prefix - The prefix container.
|
||||
* @csspart label - The menu item label.
|
||||
* @csspart suffix - The suffix container.
|
||||
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
|
||||
*/
|
||||
@customElement('sl-menu-item')
|
||||
export default class SlMenuItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private cachedTextLabel: string;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.menu-item') menuItem: HTMLElement;
|
||||
|
||||
/** The type of menu item to render. To use `checked`, this value must be set to `checkbox`. */
|
||||
@property() type: 'normal' | 'checkbox' = 'normal';
|
||||
|
||||
/** Draws the item in a checked state. */
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
|
||||
/** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */
|
||||
@property() value = '';
|
||||
|
||||
/** Draws the menu item in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener('click', this.handleHostClick);
|
||||
}
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
// Ignore the first time the label is set
|
||||
if (typeof this.cachedTextLabel === 'undefined') {
|
||||
this.cachedTextLabel = textLabel;
|
||||
return;
|
||||
}
|
||||
|
||||
// When the label changes, emit a slotchange event so parent controls see it
|
||||
if (textLabel !== this.cachedTextLabel) {
|
||||
this.cachedTextLabel = textLabel;
|
||||
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
|
||||
}
|
||||
}
|
||||
|
||||
private handleHostClick = (event: MouseEvent) => {
|
||||
// Prevent the click event from being emitted when the button is disabled or loading
|
||||
if (this.disabled) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
@watch('checked')
|
||||
handleCheckedChange() {
|
||||
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
|
||||
if (this.checked && this.type !== 'checkbox') {
|
||||
this.checked = false;
|
||||
console.error('The checked attribute can only be used on menu items with type="checkbox"', this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only checkbox types can receive the aria-checked attribute
|
||||
if (this.type === 'checkbox') {
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
} else {
|
||||
this.removeAttribute('aria-checked');
|
||||
}
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('type')
|
||||
handleTypeChange() {
|
||||
if (this.type === 'checkbox') {
|
||||
this.setAttribute('role', 'menuitemcheckbox');
|
||||
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
|
||||
} else {
|
||||
this.setAttribute('role', 'menuitem');
|
||||
this.removeAttribute('aria-checked');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a text label based on the contents of the menu item's default slot. */
|
||||
getTextLabel() {
|
||||
return getTextContent(this.defaultSlot);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
'menu-item': true,
|
||||
'menu-item--checked': this.checked,
|
||||
'menu-item--disabled': this.disabled,
|
||||
'menu-item--has-submenu': false // reserved for future use
|
||||
})}
|
||||
>
|
||||
<span part="checked-icon" class="menu-item__check">
|
||||
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
|
||||
</span>
|
||||
|
||||
<slot name="prefix" part="prefix" class="menu-item__prefix"></slot>
|
||||
|
||||
<slot part="label" class="menu-item__label" @slotchange=${this.handleDefaultSlotChange}></slot>
|
||||
|
||||
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
|
||||
|
||||
<span part="submenu-icon" class="menu-item__chevron">
|
||||
<sl-icon name="chevron-right" library="system" aria-hidden="true"></sl-icon>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu-item': SlMenuItem;
|
||||
}
|
||||
}
|
||||
import SlMenuItem from './menu-item.component.js';
|
||||
export * from './menu-item.component.js';
|
||||
export default SlMenuItem;
|
||||
SlMenuItem.define('sl-menu-item');
|
||||
|
||||
28
src/components/menu-label/menu-label.component.ts
Normal file
28
src/components/menu-label/menu-label.component.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu-label.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu labels are used to describe a group of menu items.
|
||||
* @documentation https://shoelace.style/components/menu-label
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu label's content.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
export default class SlMenuLabel extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
render() {
|
||||
return html` <slot part="base" class="menu-label"></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu-label': SlMenuLabel;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,4 @@
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu-label.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Menu labels are used to describe a group of menu items.
|
||||
* @documentation https://shoelace.style/components/menu-label
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu label's content.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
*/
|
||||
@customElement('sl-menu-label')
|
||||
export default class SlMenuLabel extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
render() {
|
||||
return html` <slot part="base" class="menu-label"></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu-label': SlMenuLabel;
|
||||
}
|
||||
}
|
||||
import SlMenuLabel from './menu-label.component.js';
|
||||
export * from './menu-label.component.js';
|
||||
export default SlMenuLabel;
|
||||
SlMenuLabel.define('sl-menu-label');
|
||||
|
||||
159
src/components/menu/menu.component.ts
Normal file
159
src/components/menu/menu.component.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlMenuItem from '../menu-item/menu-item.js';
|
||||
export interface MenuSelectEventDetail {
|
||||
item: SlMenuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Menus provide a list of options for the user to choose from.
|
||||
* @documentation https://shoelace.style/components/menu
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu's content, including menu items, menu labels, and dividers.
|
||||
*
|
||||
* @event {{ item: SlMenuItem }} sl-select - Emitted when a menu item is selected.
|
||||
*/
|
||||
export default class SlMenu extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'menu');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const item = target.closest('sl-menu-item');
|
||||
|
||||
if (!item || item.disabled || item.inert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'checkbox') {
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
|
||||
this.emit('sl-select', { detail: { item } });
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
// Make a selection when pressing enter or space
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
const item = this.getCurrentItem();
|
||||
event.preventDefault();
|
||||
|
||||
// Simulate a click to support @click handlers on menu items that also work with the keyboard
|
||||
item?.click();
|
||||
}
|
||||
|
||||
// Move the selection when pressing down or up
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
const items = this.getAllItems();
|
||||
const activeItem = this.getCurrentItem();
|
||||
let index = activeItem ? items.indexOf(activeItem) : 0;
|
||||
|
||||
if (items.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
index++;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
index--;
|
||||
} else if (event.key === 'Home') {
|
||||
index = 0;
|
||||
} else if (event.key === 'End') {
|
||||
index = items.length - 1;
|
||||
}
|
||||
|
||||
if (index < 0) {
|
||||
index = items.length - 1;
|
||||
}
|
||||
if (index > items.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
this.setCurrentItem(items[index]);
|
||||
items[index].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseDown(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (this.isMenuItem(target)) {
|
||||
this.setCurrentItem(target as SlMenuItem);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const items = this.getAllItems();
|
||||
|
||||
// Reset the roving tab index when the slotted items change
|
||||
if (items.length > 0) {
|
||||
this.setCurrentItem(items[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private isMenuItem(item: HTMLElement) {
|
||||
return (
|
||||
item.tagName.toLowerCase() === 'sl-menu-item' ||
|
||||
['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(item.getAttribute('role') ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
|
||||
getAllItems() {
|
||||
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
|
||||
if (el.inert || !this.isMenuItem(el)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) as SlMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
|
||||
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
|
||||
*/
|
||||
getCurrentItem() {
|
||||
return this.getAllItems().find(i => i.getAttribute('tabindex') === '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
|
||||
* `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
|
||||
*/
|
||||
setCurrentItem(item: SlMenuItem) {
|
||||
const items = this.getAllItems();
|
||||
|
||||
// Update tab indexes
|
||||
items.forEach(i => {
|
||||
i.setAttribute('tabindex', i === item ? '0' : '-1');
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot
|
||||
@slotchange=${this.handleSlotChange}
|
||||
@click=${this.handleClick}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@mousedown=${this.handleMouseDown}
|
||||
></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu': SlMenu;
|
||||
}
|
||||
}
|
||||
@@ -1,160 +1,4 @@
|
||||
import { customElement, query } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './menu.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlMenuItem from '../menu-item/menu-item.js';
|
||||
export interface MenuSelectEventDetail {
|
||||
item: SlMenuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Menus provide a list of options for the user to choose from.
|
||||
* @documentation https://shoelace.style/components/menu
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @slot - The menu's content, including menu items, menu labels, and dividers.
|
||||
*
|
||||
* @event {{ item: SlMenuItem }} sl-select - Emitted when a menu item is selected.
|
||||
*/
|
||||
@customElement('sl-menu')
|
||||
export default class SlMenu extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
@query('slot') defaultSlot: HTMLSlotElement;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'menu');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const item = target.closest('sl-menu-item');
|
||||
|
||||
if (!item || item.disabled || item.inert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'checkbox') {
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
|
||||
this.emit('sl-select', { detail: { item } });
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
// Make a selection when pressing enter or space
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
const item = this.getCurrentItem();
|
||||
event.preventDefault();
|
||||
|
||||
// Simulate a click to support @click handlers on menu items that also work with the keyboard
|
||||
item?.click();
|
||||
}
|
||||
|
||||
// Move the selection when pressing down or up
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||||
const items = this.getAllItems();
|
||||
const activeItem = this.getCurrentItem();
|
||||
let index = activeItem ? items.indexOf(activeItem) : 0;
|
||||
|
||||
if (items.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
index++;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
index--;
|
||||
} else if (event.key === 'Home') {
|
||||
index = 0;
|
||||
} else if (event.key === 'End') {
|
||||
index = items.length - 1;
|
||||
}
|
||||
|
||||
if (index < 0) {
|
||||
index = items.length - 1;
|
||||
}
|
||||
if (index > items.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
this.setCurrentItem(items[index]);
|
||||
items[index].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseDown(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (this.isMenuItem(target)) {
|
||||
this.setCurrentItem(target as SlMenuItem);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlotChange() {
|
||||
const items = this.getAllItems();
|
||||
|
||||
// Reset the roving tab index when the slotted items change
|
||||
if (items.length > 0) {
|
||||
this.setCurrentItem(items[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private isMenuItem(item: HTMLElement) {
|
||||
return (
|
||||
item.tagName.toLowerCase() === 'sl-menu-item' ||
|
||||
['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(item.getAttribute('role') ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
|
||||
getAllItems() {
|
||||
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
|
||||
if (el.inert || !this.isMenuItem(el)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) as SlMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
|
||||
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
|
||||
*/
|
||||
getCurrentItem() {
|
||||
return this.getAllItems().find(i => i.getAttribute('tabindex') === '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
|
||||
* `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
|
||||
*/
|
||||
setCurrentItem(item: SlMenuItem) {
|
||||
const items = this.getAllItems();
|
||||
|
||||
// Update tab indexes
|
||||
items.forEach(i => {
|
||||
i.setAttribute('tabindex', i === item ? '0' : '-1');
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot
|
||||
@slotchange=${this.handleSlotChange}
|
||||
@click=${this.handleClick}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@mousedown=${this.handleMouseDown}
|
||||
></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-menu': SlMenu;
|
||||
}
|
||||
}
|
||||
import SlMenu from './menu.component.js';
|
||||
export * from './menu.component.js';
|
||||
export default SlMenu;
|
||||
SlMenu.define('sl-menu');
|
||||
|
||||
119
src/components/mutation-observer/mutation-observer.component.ts
Normal file
119
src/components/mutation-observer/mutation-observer.component.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './mutation-observer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary The Mutation Observer component offers a thin, declarative interface to the [`MutationObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver).
|
||||
* @documentation https://shoelace.style/components/mutation-observer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event {{ mutationList: MutationRecord[] }} sl-mutation - Emitted when a mutation occurs.
|
||||
*
|
||||
* @slot - The content to watch for mutations.
|
||||
*/
|
||||
export default class SlMutationObserver extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private mutationObserver: MutationObserver;
|
||||
|
||||
/**
|
||||
* Watches for changes to attributes. To watch only specific attributes, separate them by a space, e.g.
|
||||
* `attr="class id title"`. To watch all attributes, use `*`.
|
||||
*/
|
||||
@property({ reflect: true }) attr: string;
|
||||
|
||||
/** Indicates whether or not the attribute's previous value should be recorded when monitoring changes. */
|
||||
@property({ attribute: 'attr-old-value', type: Boolean, reflect: true }) attrOldValue = false;
|
||||
|
||||
/** Watches for changes to the character data contained within the node. */
|
||||
@property({ attribute: 'char-data', type: Boolean, reflect: true }) charData = false;
|
||||
|
||||
/** Indicates whether or not the previous value of the node's text should be recorded. */
|
||||
@property({ attribute: 'char-data-old-value', type: Boolean, reflect: true }) charDataOldValue = false;
|
||||
|
||||
/** Watches for the addition or removal of new child nodes. */
|
||||
@property({ attribute: 'child-list', type: Boolean, reflect: true }) childList = false;
|
||||
|
||||
/** Disables the observer. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.mutationObserver = new MutationObserver(this.handleMutation);
|
||||
|
||||
if (!this.disabled) {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stopObserver();
|
||||
}
|
||||
|
||||
private handleMutation = (mutationList: MutationRecord[]) => {
|
||||
this.emit('sl-mutation', {
|
||||
detail: { mutationList }
|
||||
});
|
||||
};
|
||||
|
||||
private startObserver() {
|
||||
const observeAttributes = typeof this.attr === 'string' && this.attr.length > 0;
|
||||
const attributeFilter = observeAttributes && this.attr !== '*' ? this.attr.split(' ') : undefined;
|
||||
|
||||
try {
|
||||
this.mutationObserver.observe(this, {
|
||||
subtree: true,
|
||||
childList: this.childList,
|
||||
attributes: observeAttributes,
|
||||
attributeFilter,
|
||||
attributeOldValue: this.attrOldValue,
|
||||
characterData: this.charData,
|
||||
characterDataOldValue: this.charDataOldValue
|
||||
});
|
||||
} catch {
|
||||
//
|
||||
// A mutation observer was created without one of the required attributes: attr, char-data, or child-list. The
|
||||
// browser will normally throw an error, but we'll suppress that so it doesn't appear as attributes are added
|
||||
// and removed.
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
private stopObserver() {
|
||||
this.mutationObserver.disconnect();
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
if (this.disabled) {
|
||||
this.stopObserver();
|
||||
} else {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('attr', { waitUntilFirstUpdate: true })
|
||||
@watch('attr-old-value', { waitUntilFirstUpdate: true })
|
||||
@watch('char-data', { waitUntilFirstUpdate: true })
|
||||
@watch('char-data-old-value', { waitUntilFirstUpdate: true })
|
||||
@watch('childList', { waitUntilFirstUpdate: true })
|
||||
handleChange() {
|
||||
this.stopObserver();
|
||||
this.startObserver();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-mutation-observer': SlMutationObserver;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,4 @@
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './mutation-observer.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary The Mutation Observer component offers a thin, declarative interface to the [`MutationObserver API`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver).
|
||||
* @documentation https://shoelace.style/components/mutation-observer
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event {{ mutationList: MutationRecord[] }} sl-mutation - Emitted when a mutation occurs.
|
||||
*
|
||||
* @slot - The content to watch for mutations.
|
||||
*/
|
||||
@customElement('sl-mutation-observer')
|
||||
export default class SlMutationObserver extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private mutationObserver: MutationObserver;
|
||||
|
||||
/**
|
||||
* Watches for changes to attributes. To watch only specific attributes, separate them by a space, e.g.
|
||||
* `attr="class id title"`. To watch all attributes, use `*`.
|
||||
*/
|
||||
@property({ reflect: true }) attr: string;
|
||||
|
||||
/** Indicates whether or not the attribute's previous value should be recorded when monitoring changes. */
|
||||
@property({ attribute: 'attr-old-value', type: Boolean, reflect: true }) attrOldValue = false;
|
||||
|
||||
/** Watches for changes to the character data contained within the node. */
|
||||
@property({ attribute: 'char-data', type: Boolean, reflect: true }) charData = false;
|
||||
|
||||
/** Indicates whether or not the previous value of the node's text should be recorded. */
|
||||
@property({ attribute: 'char-data-old-value', type: Boolean, reflect: true }) charDataOldValue = false;
|
||||
|
||||
/** Watches for the addition or removal of new child nodes. */
|
||||
@property({ attribute: 'child-list', type: Boolean, reflect: true }) childList = false;
|
||||
|
||||
/** Disables the observer. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.mutationObserver = new MutationObserver(this.handleMutation);
|
||||
|
||||
if (!this.disabled) {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stopObserver();
|
||||
}
|
||||
|
||||
private handleMutation = (mutationList: MutationRecord[]) => {
|
||||
this.emit('sl-mutation', {
|
||||
detail: { mutationList }
|
||||
});
|
||||
};
|
||||
|
||||
private startObserver() {
|
||||
const observeAttributes = typeof this.attr === 'string' && this.attr.length > 0;
|
||||
const attributeFilter = observeAttributes && this.attr !== '*' ? this.attr.split(' ') : undefined;
|
||||
|
||||
try {
|
||||
this.mutationObserver.observe(this, {
|
||||
subtree: true,
|
||||
childList: this.childList,
|
||||
attributes: observeAttributes,
|
||||
attributeFilter,
|
||||
attributeOldValue: this.attrOldValue,
|
||||
characterData: this.charData,
|
||||
characterDataOldValue: this.charDataOldValue
|
||||
});
|
||||
} catch {
|
||||
//
|
||||
// A mutation observer was created without one of the required attributes: attr, char-data, or child-list. The
|
||||
// browser will normally throw an error, but we'll suppress that so it doesn't appear as attributes are added
|
||||
// and removed.
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
private stopObserver() {
|
||||
this.mutationObserver.disconnect();
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
if (this.disabled) {
|
||||
this.stopObserver();
|
||||
} else {
|
||||
this.startObserver();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('attr', { waitUntilFirstUpdate: true })
|
||||
@watch('attr-old-value', { waitUntilFirstUpdate: true })
|
||||
@watch('char-data', { waitUntilFirstUpdate: true })
|
||||
@watch('char-data-old-value', { waitUntilFirstUpdate: true })
|
||||
@watch('childList', { waitUntilFirstUpdate: true })
|
||||
handleChange() {
|
||||
this.stopObserver();
|
||||
this.startObserver();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-mutation-observer': SlMutationObserver;
|
||||
}
|
||||
}
|
||||
import SlMutationObserver from './mutation-observer.component.js';
|
||||
export * from './mutation-observer.component.js';
|
||||
export default SlMutationObserver;
|
||||
SlMutationObserver.define('sl-mutation-observer');
|
||||
|
||||
139
src/components/option/option.component.ts
Normal file
139
src/components/option/option.component.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './option.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Options define the selectable items within various form controls such as [select](/components/select).
|
||||
* @documentation https://shoelace.style/components/option
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The option's label.
|
||||
* @slot prefix - Used to prepend an icon or similar element to the menu item.
|
||||
* @slot suffix - Used to append an icon or similar element to the menu item.
|
||||
*
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The option's label.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
export default class SlOption extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
static dependencies = { 'sl-icon': SlIcon };
|
||||
|
||||
private cachedTextLabel: string;
|
||||
// @ts-expect-error - Controller is currently unused
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.option__label') defaultSlot: HTMLSlotElement;
|
||||
|
||||
@state() current = false; // the user has keyed into the option, but hasn't selected it yet (shows a highlight)
|
||||
@state() selected = false; // the option is selected and has aria-selected="true"
|
||||
@state() hasHover = false; // we need this because Safari doesn't honor :hover styles while dragging
|
||||
|
||||
/**
|
||||
* The option's value. When selected, the containing form control will receive this value. The value must be unique
|
||||
* from other options in the same group. Values may not contain spaces, as spaces are used as delimiters when listing
|
||||
* multiple values.
|
||||
*/
|
||||
@property({ reflect: true }) value = '';
|
||||
|
||||
/** Draws the option in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'option');
|
||||
this.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
// Ignore the first time the label is set
|
||||
if (typeof this.cachedTextLabel === 'undefined') {
|
||||
this.cachedTextLabel = textLabel;
|
||||
return;
|
||||
}
|
||||
|
||||
// When the label changes, emit a slotchange event so parent controls see it
|
||||
if (textLabel !== this.cachedTextLabel) {
|
||||
this.cachedTextLabel = textLabel;
|
||||
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseEnter() {
|
||||
this.hasHover = true;
|
||||
}
|
||||
|
||||
private handleMouseLeave() {
|
||||
this.hasHover = false;
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('selected')
|
||||
handleSelectedChange() {
|
||||
this.setAttribute('aria-selected', this.selected ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('value')
|
||||
handleValueChange() {
|
||||
// Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers
|
||||
// instead of requiring them to cast the value to a string.
|
||||
if (typeof this.value !== 'string') {
|
||||
this.value = String(this.value);
|
||||
}
|
||||
|
||||
if (this.value.includes(' ')) {
|
||||
console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this);
|
||||
this.value = this.value.replace(/ /g, '_');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a plain text label based on the option's content. */
|
||||
getTextLabel() {
|
||||
return (this.textContent ?? '').trim();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
option: true,
|
||||
'option--current': this.current,
|
||||
'option--disabled': this.disabled,
|
||||
'option--selected': this.selected,
|
||||
'option--hover': this.hasHover
|
||||
})}
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
@mouseleave=${this.handleMouseLeave}
|
||||
>
|
||||
<sl-icon part="checked-icon" class="option__check" name="check" library="system" aria-hidden="true"></sl-icon>
|
||||
<slot part="prefix" name="prefix" class="option__prefix"></slot>
|
||||
<slot part="label" class="option__label" @slotchange=${this.handleDefaultSlotChange}></slot>
|
||||
<slot part="suffix" name="suffix" class="option__suffix"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-option': SlOption;
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,4 @@
|
||||
import '../icon/icon.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './option.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
/**
|
||||
* @summary Options define the selectable items within various form controls such as [select](/components/select).
|
||||
* @documentation https://shoelace.style/components/option
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @dependency sl-icon
|
||||
*
|
||||
* @slot - The option's label.
|
||||
* @slot prefix - Used to prepend an icon or similar element to the menu item.
|
||||
* @slot suffix - Used to append an icon or similar element to the menu item.
|
||||
*
|
||||
* @csspart checked-icon - The checked icon, an `<sl-icon>` element.
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart label - The option's label.
|
||||
* @csspart prefix - The container that wraps the prefix.
|
||||
* @csspart suffix - The container that wraps the suffix.
|
||||
*/
|
||||
@customElement('sl-option')
|
||||
export default class SlOption extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private cachedTextLabel: string;
|
||||
// @ts-expect-error - Controller is currently unused
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('.option__label') defaultSlot: HTMLSlotElement;
|
||||
|
||||
@state() current = false; // the user has keyed into the option, but hasn't selected it yet (shows a highlight)
|
||||
@state() selected = false; // the option is selected and has aria-selected="true"
|
||||
@state() hasHover = false; // we need this because Safari doesn't honor :hover styles while dragging
|
||||
|
||||
/**
|
||||
* The option's value. When selected, the containing form control will receive this value. The value must be unique
|
||||
* from other options in the same group. Values may not contain spaces, as spaces are used as delimiters when listing
|
||||
* multiple values.
|
||||
*/
|
||||
@property({ reflect: true }) value = '';
|
||||
|
||||
/** Draws the option in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'option');
|
||||
this.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
// Ignore the first time the label is set
|
||||
if (typeof this.cachedTextLabel === 'undefined') {
|
||||
this.cachedTextLabel = textLabel;
|
||||
return;
|
||||
}
|
||||
|
||||
// When the label changes, emit a slotchange event so parent controls see it
|
||||
if (textLabel !== this.cachedTextLabel) {
|
||||
this.cachedTextLabel = textLabel;
|
||||
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseEnter() {
|
||||
this.hasHover = true;
|
||||
}
|
||||
|
||||
private handleMouseLeave() {
|
||||
this.hasHover = false;
|
||||
}
|
||||
|
||||
@watch('disabled')
|
||||
handleDisabledChange() {
|
||||
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('selected')
|
||||
handleSelectedChange() {
|
||||
this.setAttribute('aria-selected', this.selected ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@watch('value')
|
||||
handleValueChange() {
|
||||
// Ensure the value is a string. This ensures the next line doesn't error and allows framework users to pass numbers
|
||||
// instead of requiring them to cast the value to a string.
|
||||
if (typeof this.value !== 'string') {
|
||||
this.value = String(this.value);
|
||||
}
|
||||
|
||||
if (this.value.includes(' ')) {
|
||||
console.error(`Option values cannot include a space. All spaces have been replaced with underscores.`, this);
|
||||
this.value = this.value.replace(/ /g, '_');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a plain text label based on the option's content. */
|
||||
getTextLabel() {
|
||||
return (this.textContent ?? '').trim();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
part="base"
|
||||
class=${classMap({
|
||||
option: true,
|
||||
'option--current': this.current,
|
||||
'option--disabled': this.disabled,
|
||||
'option--selected': this.selected,
|
||||
'option--hover': this.hasHover
|
||||
})}
|
||||
@mouseenter=${this.handleMouseEnter}
|
||||
@mouseleave=${this.handleMouseLeave}
|
||||
>
|
||||
<sl-icon part="checked-icon" class="option__check" name="check" library="system" aria-hidden="true"></sl-icon>
|
||||
<slot part="prefix" name="prefix" class="option__prefix"></slot>
|
||||
<slot part="label" class="option__label" @slotchange=${this.handleDefaultSlotChange}></slot>
|
||||
<slot part="suffix" name="suffix" class="option__suffix"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-option': SlOption;
|
||||
}
|
||||
}
|
||||
import SlOption from './option.component.js';
|
||||
export * from './option.component.js';
|
||||
export default SlOption;
|
||||
SlOption.define('sl-option');
|
||||
|
||||
479
src/components/popup/popup.component.ts
Normal file
479
src/components/popup/popup.component.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { arrow, autoUpdate, computePosition, flip, offset, platform, shift, size } from '@floating-ui/dom';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { html } from 'lit';
|
||||
import { offsetParent } from 'composed-offset-position';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import styles from './popup.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
|
||||
export interface VirtualElement {
|
||||
getBoundingClientRect: () => DOMRect;
|
||||
}
|
||||
|
||||
function isVirtualElement(e: unknown): e is VirtualElement {
|
||||
return e !== null && typeof e === 'object' && 'getBoundingClientRect' in e;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Popup is a utility that lets you declaratively anchor "popup" containers to another element.
|
||||
* @documentation https://shoelace.style/components/popup
|
||||
* @status stable
|
||||
* @since 2.0
|
||||
*
|
||||
* @event sl-reposition - Emitted when the popup is repositioned. This event can fire a lot, so avoid putting expensive
|
||||
* operations in your listener or consider debouncing it.
|
||||
*
|
||||
* @slot - The popup's content.
|
||||
* @slot anchor - The element the popup will be anchored to. If the anchor lives outside of the popup, you can use the
|
||||
* `anchor` attribute or property instead.
|
||||
*
|
||||
* @csspart arrow - The arrow's container. Avoid setting `top|bottom|left|right` properties, as these values are
|
||||
* assigned dynamically as the popup moves. This is most useful for applying a background color to match the popup, and
|
||||
* maybe a border or box shadow.
|
||||
* @csspart popup - The popup's container. Useful for setting a background color, box shadow, etc.
|
||||
*
|
||||
* @cssproperty [--arrow-size=6px] - The size of the arrow. Note that an arrow won't be shown unless the `arrow`
|
||||
* attribute is used.
|
||||
* @cssproperty [--arrow-color=var(--sl-color-neutral-0)] - The color of the arrow.
|
||||
* @cssproperty [--auto-size-available-width] - A read-only custom property that determines the amount of width the
|
||||
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
|
||||
* available when using `auto-size`.
|
||||
* @cssproperty [--auto-size-available-height] - A read-only custom property that determines the amount of height the
|
||||
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
|
||||
* available when using `auto-size`.
|
||||
*/
|
||||
export default class SlPopup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private anchorEl: Element | VirtualElement | null;
|
||||
private cleanup: ReturnType<typeof autoUpdate> | undefined;
|
||||
|
||||
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
|
||||
@query('.popup') popup: HTMLElement;
|
||||
@query('.popup__arrow') private arrowEl: HTMLElement;
|
||||
|
||||
/**
|
||||
* The element the popup will be anchored to. If the anchor lives outside of the popup, you can provide the anchor
|
||||
* element `id`, a DOM element reference, or a `VirtualElement`. If the anchor lives inside the popup, use the
|
||||
* `anchor` slot instead.
|
||||
*/
|
||||
@property() anchor: Element | string | VirtualElement;
|
||||
|
||||
/**
|
||||
* Activates the positioning logic and shows the popup. When this attribute is removed, the positioning logic is torn
|
||||
* down and the popup will be hidden.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) active = false;
|
||||
|
||||
/**
|
||||
* The preferred placement of the popup. Note that the actual placement will vary as configured to keep the
|
||||
* panel inside of the viewport.
|
||||
*/
|
||||
@property({ reflect: true }) placement:
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end' = 'top';
|
||||
|
||||
/**
|
||||
* Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if overflow is
|
||||
* clipped, using a `fixed` position strategy can often workaround it.
|
||||
*/
|
||||
@property({ reflect: true }) strategy: 'absolute' | 'fixed' = 'absolute';
|
||||
|
||||
/** The distance in pixels from which to offset the panel away from its anchor. */
|
||||
@property({ type: Number }) distance = 0;
|
||||
|
||||
/** The distance in pixels from which to offset the panel along its anchor. */
|
||||
@property({ type: Number }) skidding = 0;
|
||||
|
||||
/**
|
||||
* Attaches an arrow to the popup. The arrow's size and color can be customized using the `--arrow-size` and
|
||||
* `--arrow-color` custom properties. For additional customizations, you can also target the arrow using
|
||||
* `::part(arrow)` in your stylesheet.
|
||||
*/
|
||||
@property({ type: Boolean }) arrow = false;
|
||||
|
||||
/**
|
||||
* The placement of the arrow. The default is `anchor`, which will align the arrow as close to the center of the
|
||||
* anchor as possible, considering available space and `arrow-padding`. A value of `start`, `end`, or `center` will
|
||||
* align the arrow to the start, end, or center of the popover instead.
|
||||
*/
|
||||
@property({ attribute: 'arrow-placement' }) arrowPlacement: 'start' | 'end' | 'center' | 'anchor' = 'anchor';
|
||||
|
||||
/**
|
||||
* The amount of padding between the arrow and the edges of the popup. If the popup has a border-radius, for example,
|
||||
* this will prevent it from overflowing the corners.
|
||||
*/
|
||||
@property({ attribute: 'arrow-padding', type: Number }) arrowPadding = 10;
|
||||
|
||||
/**
|
||||
* When set, placement of the popup will flip to the opposite site to keep it in view. You can use
|
||||
* `flipFallbackPlacements` to further configure how the fallback placement is determined.
|
||||
*/
|
||||
@property({ type: Boolean }) flip = false;
|
||||
|
||||
/**
|
||||
* If the preferred placement doesn't fit, popup will be tested in these fallback placements until one fits. Must be a
|
||||
* string of any number of placements separated by a space, e.g. "top bottom left". If no placement fits, the flip
|
||||
* fallback strategy will be used instead.
|
||||
* */
|
||||
@property({
|
||||
attribute: 'flip-fallback-placements',
|
||||
converter: {
|
||||
fromAttribute: (value: string) => {
|
||||
return value
|
||||
.split(' ')
|
||||
.map(p => p.trim())
|
||||
.filter(p => p !== '');
|
||||
},
|
||||
toAttribute: (value: []) => {
|
||||
return value.join(' ');
|
||||
}
|
||||
}
|
||||
})
|
||||
flipFallbackPlacements = '';
|
||||
|
||||
/**
|
||||
* When neither the preferred placement nor the fallback placements fit, this value will be used to determine whether
|
||||
* the popup should be positioned using the best available fit based on available space or as it was initially
|
||||
* preferred.
|
||||
*/
|
||||
@property({ attribute: 'flip-fallback-strategy' }) flipFallbackStrategy: 'best-fit' | 'initial' = 'best-fit';
|
||||
|
||||
/**
|
||||
* The flip boundary describes clipping element(s) that overflow will be checked relative to when flipping. By
|
||||
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
|
||||
* change the boundary by passing a reference to one or more elements to this property.
|
||||
*/
|
||||
@property({ type: Object }) flipBoundary: Element | Element[];
|
||||
|
||||
/** The amount of padding, in pixels, to exceed before the flip behavior will occur. */
|
||||
@property({ attribute: 'flip-padding', type: Number }) flipPadding = 0;
|
||||
|
||||
/** Moves the popup along the axis to keep it in view when clipped. */
|
||||
@property({ type: Boolean }) shift = false;
|
||||
|
||||
/**
|
||||
* The shift boundary describes clipping element(s) that overflow will be checked relative to when shifting. By
|
||||
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
|
||||
* change the boundary by passing a reference to one or more elements to this property.
|
||||
*/
|
||||
@property({ type: Object }) shiftBoundary: Element | Element[];
|
||||
|
||||
/** The amount of padding, in pixels, to exceed before the shift behavior will occur. */
|
||||
@property({ attribute: 'shift-padding', type: Number }) shiftPadding = 0;
|
||||
|
||||
/** When set, this will cause the popup to automatically resize itself to prevent it from overflowing. */
|
||||
@property({ attribute: 'auto-size' }) autoSize: 'horizontal' | 'vertical' | 'both';
|
||||
|
||||
/** Syncs the popup's width or height to that of the anchor element. */
|
||||
@property() sync: 'width' | 'height' | 'both';
|
||||
|
||||
/**
|
||||
* The auto-size boundary describes clipping element(s) that overflow will be checked relative to when resizing. By
|
||||
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
|
||||
* change the boundary by passing a reference to one or more elements to this property.
|
||||
*/
|
||||
@property({ type: Object }) autoSizeBoundary: Element | Element[];
|
||||
|
||||
/** The amount of padding, in pixels, to exceed before the auto-size behavior will occur. */
|
||||
@property({ attribute: 'auto-size-padding', type: Number }) autoSizePadding = 0;
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Start the positioner after the first update
|
||||
await this.updateComplete;
|
||||
this.start();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
async updated(changedProps: Map<string, unknown>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
// Start or stop the positioner when active changes
|
||||
if (changedProps.has('active')) {
|
||||
if (this.active) {
|
||||
this.start();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Update the anchor when anchor changes
|
||||
if (changedProps.has('anchor')) {
|
||||
this.handleAnchorChange();
|
||||
}
|
||||
|
||||
// All other properties will trigger a reposition when active
|
||||
if (this.active) {
|
||||
await this.updateComplete;
|
||||
this.reposition();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAnchorChange() {
|
||||
await this.stop();
|
||||
|
||||
if (this.anchor && typeof this.anchor === 'string') {
|
||||
// Locate the anchor by id
|
||||
const root = this.getRootNode() as Document | ShadowRoot;
|
||||
this.anchorEl = root.getElementById(this.anchor);
|
||||
} else if (this.anchor instanceof Element || isVirtualElement(this.anchor)) {
|
||||
// Use the anchor's reference
|
||||
this.anchorEl = this.anchor;
|
||||
} else {
|
||||
// Look for a slotted anchor
|
||||
this.anchorEl = this.querySelector<HTMLElement>('[slot="anchor"]');
|
||||
}
|
||||
|
||||
// If the anchor is a <slot>, we'll use the first assigned element as the target since slots use `display: contents`
|
||||
// and positioning can't be calculated on them
|
||||
if (this.anchorEl instanceof HTMLSlotElement) {
|
||||
this.anchorEl = this.anchorEl.assignedElements({ flatten: true })[0] as HTMLElement;
|
||||
}
|
||||
|
||||
if (!this.anchorEl) {
|
||||
throw new Error(
|
||||
'Invalid anchor element: no anchor could be found using the anchor slot or the anchor attribute.'
|
||||
);
|
||||
}
|
||||
|
||||
this.start();
|
||||
}
|
||||
|
||||
private start() {
|
||||
// We can't start the positioner without an anchor
|
||||
if (!this.anchorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanup = autoUpdate(this.anchorEl, this.popup, () => {
|
||||
this.reposition();
|
||||
});
|
||||
}
|
||||
|
||||
private async stop(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (this.cleanup) {
|
||||
this.cleanup();
|
||||
this.cleanup = undefined;
|
||||
this.removeAttribute('data-current-placement');
|
||||
this.style.removeProperty('--auto-size-available-width');
|
||||
this.style.removeProperty('--auto-size-available-height');
|
||||
requestAnimationFrame(() => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Forces the popup to recalculate and reposition itself. */
|
||||
reposition() {
|
||||
// Nothing to do if the popup is inactive or the anchor doesn't exist
|
||||
if (!this.active || !this.anchorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// NOTE: Floating UI middlewares are order dependent: https://floating-ui.com/docs/middleware
|
||||
//
|
||||
const middleware = [
|
||||
// The offset middleware goes first
|
||||
offset({ mainAxis: this.distance, crossAxis: this.skidding })
|
||||
];
|
||||
|
||||
// First we sync width/height
|
||||
if (this.sync) {
|
||||
middleware.push(
|
||||
size({
|
||||
apply: ({ rects }) => {
|
||||
const syncWidth = this.sync === 'width' || this.sync === 'both';
|
||||
const syncHeight = this.sync === 'height' || this.sync === 'both';
|
||||
this.popup.style.width = syncWidth ? `${rects.reference.width}px` : '';
|
||||
this.popup.style.height = syncHeight ? `${rects.reference.height}px` : '';
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Cleanup styles if we're not matching width/height
|
||||
this.popup.style.width = '';
|
||||
this.popup.style.height = '';
|
||||
}
|
||||
|
||||
// Then we flip
|
||||
if (this.flip) {
|
||||
middleware.push(
|
||||
flip({
|
||||
boundary: this.flipBoundary,
|
||||
// @ts-expect-error - We're converting a string attribute to an array here
|
||||
fallbackPlacements: this.flipFallbackPlacements,
|
||||
fallbackStrategy: this.flipFallbackStrategy === 'best-fit' ? 'bestFit' : 'initialPlacement',
|
||||
padding: this.flipPadding
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Then we shift
|
||||
if (this.shift) {
|
||||
middleware.push(
|
||||
shift({
|
||||
boundary: this.shiftBoundary,
|
||||
padding: this.shiftPadding
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Now we adjust the size as needed
|
||||
if (this.autoSize) {
|
||||
middleware.push(
|
||||
size({
|
||||
boundary: this.autoSizeBoundary,
|
||||
padding: this.autoSizePadding,
|
||||
apply: ({ availableWidth, availableHeight }) => {
|
||||
if (this.autoSize === 'vertical' || this.autoSize === 'both') {
|
||||
this.style.setProperty('--auto-size-available-height', `${availableHeight}px`);
|
||||
} else {
|
||||
this.style.removeProperty('--auto-size-available-height');
|
||||
}
|
||||
|
||||
if (this.autoSize === 'horizontal' || this.autoSize === 'both') {
|
||||
this.style.setProperty('--auto-size-available-width', `${availableWidth}px`);
|
||||
} else {
|
||||
this.style.removeProperty('--auto-size-available-width');
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Cleanup styles if we're no longer using auto-size
|
||||
this.style.removeProperty('--auto-size-available-width');
|
||||
this.style.removeProperty('--auto-size-available-height');
|
||||
}
|
||||
|
||||
// Finally, we add an arrow
|
||||
if (this.arrow) {
|
||||
middleware.push(
|
||||
arrow({
|
||||
element: this.arrowEl,
|
||||
padding: this.arrowPadding
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Use custom positioning logic if the strategy is absolute. Otherwise, fall back to the default logic.
|
||||
//
|
||||
// More info: https://github.com/shoelace-style/shoelace/issues/1135
|
||||
//
|
||||
const getOffsetParent =
|
||||
this.strategy === 'absolute'
|
||||
? (element: Element) => platform.getOffsetParent(element, offsetParent)
|
||||
: platform.getOffsetParent;
|
||||
|
||||
computePosition(this.anchorEl, this.popup, {
|
||||
placement: this.placement,
|
||||
middleware,
|
||||
strategy: this.strategy,
|
||||
platform: {
|
||||
...platform,
|
||||
getOffsetParent
|
||||
}
|
||||
}).then(({ x, y, middlewareData, placement }) => {
|
||||
//
|
||||
// Even though we have our own localization utility, it uses different heuristics to determine RTL. Because of
|
||||
// that, we'll use the same approach that Floating UI uses.
|
||||
//
|
||||
// Source: https://github.com/floating-ui/floating-ui/blob/cb3b6ab07f95275730d3e6e46c702f8d4908b55c/packages/dom/src/utils/getDocumentRect.ts#L31
|
||||
//
|
||||
const isRtl = getComputedStyle(this).direction === 'rtl';
|
||||
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]]!;
|
||||
|
||||
this.setAttribute('data-current-placement', placement);
|
||||
|
||||
Object.assign(this.popup.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`
|
||||
});
|
||||
|
||||
if (this.arrow) {
|
||||
const arrowX = middlewareData.arrow!.x;
|
||||
const arrowY = middlewareData.arrow!.y;
|
||||
let top = '';
|
||||
let right = '';
|
||||
let bottom = '';
|
||||
let left = '';
|
||||
|
||||
if (this.arrowPlacement === 'start') {
|
||||
// Start
|
||||
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
top = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
right = isRtl ? value : '';
|
||||
left = isRtl ? '' : value;
|
||||
} else if (this.arrowPlacement === 'end') {
|
||||
// End
|
||||
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
right = isRtl ? '' : value;
|
||||
left = isRtl ? value : '';
|
||||
bottom = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
|
||||
} else if (this.arrowPlacement === 'center') {
|
||||
// Center
|
||||
left = typeof arrowX === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
|
||||
top = typeof arrowY === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
|
||||
} else {
|
||||
// Anchor (default)
|
||||
left = typeof arrowX === 'number' ? `${arrowX}px` : '';
|
||||
top = typeof arrowY === 'number' ? `${arrowY}px` : '';
|
||||
}
|
||||
|
||||
Object.assign(this.arrowEl.style, {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left,
|
||||
[staticSide]: 'calc(var(--arrow-size-diagonal) * -1)'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.emit('sl-reposition');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<slot name="anchor" @slotchange=${this.handleAnchorChange}></slot>
|
||||
|
||||
<div
|
||||
part="popup"
|
||||
class=${classMap({
|
||||
popup: true,
|
||||
'popup--active': this.active,
|
||||
'popup--fixed': this.strategy === 'fixed',
|
||||
'popup--has-arrow': this.arrow
|
||||
})}
|
||||
>
|
||||
<slot></slot>
|
||||
${this.arrow ? html`<div part="arrow" class="popup__arrow" role="presentation"></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sl-popup': SlPopup;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user