kopia lustrzana https://github.com/shoelace-style/shoelace
Shoemaker rework
rodzic
4eeeffc493
commit
fe45f2159f
|
@ -1,39 +0,0 @@
|
||||||
{
|
|
||||||
"parserOptions": {
|
|
||||||
"project": "./tsconfig.json"
|
|
||||||
},
|
|
||||||
"extends": ["plugin:@stencil/recommended"],
|
|
||||||
"rules": {
|
|
||||||
"@stencil/async-methods": "error",
|
|
||||||
"@stencil/ban-prefix": ["error", ["stencil", "stnl", "st"]],
|
|
||||||
"@stencil/decorators-context": "error",
|
|
||||||
"@stencil/decorators-style": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"prop": "inline",
|
|
||||||
"state": "inline",
|
|
||||||
"element": "inline",
|
|
||||||
"event": "inline",
|
|
||||||
"method": "multiline",
|
|
||||||
"watch": "multiline",
|
|
||||||
"listen": "multiline"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@stencil/element-type": "error",
|
|
||||||
"@stencil/host-data-deprecated": "error",
|
|
||||||
"@stencil/methods-must-be-public": "error",
|
|
||||||
"@stencil/no-unused-watch": "error",
|
|
||||||
"@stencil/own-methods-must-be-private": "off",
|
|
||||||
"@stencil/own-props-must-be-private": "off",
|
|
||||||
"@stencil/prefer-vdom-listener": "error",
|
|
||||||
"@stencil/props-must-be-public": "off",
|
|
||||||
"@stencil/props-must-be-readonly": "off",
|
|
||||||
"@stencil/render-returns-host": "error",
|
|
||||||
"@stencil/required-jsdoc": "error",
|
|
||||||
"@stencil/reserved-member-names": "error",
|
|
||||||
"@stencil/single-export": "error",
|
|
||||||
"@stencil/strict-boolean-conditions": "off",
|
|
||||||
"@stencil/strict-mutable": "off",
|
|
||||||
"react/jsx-no-bind": "off"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,34 +1,6 @@
|
||||||
src/components/*/readme.md
|
|
||||||
src/components/icon/icons
|
|
||||||
docs/assets/data/custom.json
|
|
||||||
docs/assets/icons/sprite.svg
|
|
||||||
|
|
||||||
dist/
|
|
||||||
docs/dist/
|
|
||||||
docs/themes/
|
|
||||||
loader/
|
|
||||||
temp/
|
|
||||||
|
|
||||||
*~
|
|
||||||
*.sw[mnpcod]
|
|
||||||
*.log
|
|
||||||
*.lock
|
|
||||||
*.tmp
|
|
||||||
*.tmp.*
|
|
||||||
log.txt
|
|
||||||
*.sublime-project
|
|
||||||
*.sublime-workspace
|
|
||||||
|
|
||||||
.cache/
|
|
||||||
.stencil/
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
.sass-cache/
|
|
||||||
.versions/
|
|
||||||
node_modules/
|
|
||||||
$RECYCLE.BIN/
|
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
.cache
|
||||||
UserInterfaceState.xcuserstate
|
docs/dist
|
||||||
.env
|
dist
|
||||||
|
examples
|
||||||
|
node_modules
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
.github
|
*.md
|
||||||
.cache
|
.cache
|
||||||
.stencil
|
.github
|
||||||
dist
|
dist
|
||||||
docs/assets
|
docs/*.md
|
||||||
docs/**/*.md
|
src/components/icon/icons
|
||||||
loader
|
|
||||||
node_modules
|
node_modules
|
||||||
src/components/**/readme.md
|
|
||||||
src/components.d.ts
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
tsconfig.json
|
||||||
|
|
12
README.md
12
README.md
|
@ -31,9 +31,7 @@ If that's not what you're trying to do, the [documentation website](https://shoe
|
||||||
|
|
||||||
### What are you using to build Shoelace?
|
### What are you using to build Shoelace?
|
||||||
|
|
||||||
Components are built with [Stencil](https://stenciljs.com/), a compiler that generates standards-based web components. The source code is a combination of TypeScript + JSX (TSX). Stylesheets are written in SCSS.
|
Components are built with [Shoemaker](https://github.com/shoelace-style/shoemaker), a lightweight utility that provides an elegant API and reactive data binding. The build is a custom script with bundling powered by [esbuild](https://esbuild.github.io/).
|
||||||
|
|
||||||
The build is done through a combination of Stencil's CLI and a handful of custom scripts.
|
|
||||||
|
|
||||||
### Forking the Repo
|
### Forking the Repo
|
||||||
|
|
||||||
|
@ -50,14 +48,14 @@ npm install
|
||||||
Once you've cloned the repo, run the following command.
|
Once you've cloned the repo, run the following command.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
This will spin up the Shoelace dev server. Note that the dev server requires ports 4000, 4001, and 4002 to be available.
|
This will spin up the Shoelace dev server. After the initial build, a browser will open automatically.
|
||||||
|
|
||||||
After the initial build, a browser will open at `http://localhost:4000`.
|
There is currently no hot module reloading (HMR), as browser's don't provide a way to reregister custom elements, but most changes to the source will reload the browser automatically. The exception is component metadata used by the docs, which is generated by TypeDoc. This tool takes a few seconds to run so, to prevent long reload delays, it only runs once at startup.
|
||||||
|
|
||||||
Hot module reloading (HMR) is enabled for components, so changes will instantly reflect in the browser as you work. The documentation is powered by Docsify, which uses raw markdown files to generate pages. As such, no static files are built for the docs. Unfortunately, changes to _documentation pages_ will trigger a page refresh (no HMR).
|
The documentation is powered by Docsify, which uses raw markdown files to generate pages. As such, no static files are built for the docs.
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
|
|
|
@ -1,77 +0,0 @@
|
||||||
//
|
|
||||||
// The Shoelace dev server! 🥾
|
|
||||||
//
|
|
||||||
// This is an Express + Browsersync script that:
|
|
||||||
//
|
|
||||||
// - Proxies Stencil's dev server (for HMR of components)
|
|
||||||
// - Serves dist/ and docs/ from https://localhost:3000/
|
|
||||||
// - Launches the docs site and reloads the page when pages are modified
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// 1. Run Stencil: `stencil build --dev --docs --watch --serve --no-open`
|
|
||||||
//
|
|
||||||
// 2. Run this script at the same time as Stencil
|
|
||||||
//
|
|
||||||
|
|
||||||
const bs = require('browser-sync').create();
|
|
||||||
const chalk = require('chalk');
|
|
||||||
const express = require('express');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const browserPort = 4000;
|
|
||||||
const stencilPort = 4001;
|
|
||||||
const proxyPort = 4002;
|
|
||||||
|
|
||||||
// Proxy Stencil's dev server
|
|
||||||
app.use(
|
|
||||||
'/~dev-server',
|
|
||||||
createProxyMiddleware({
|
|
||||||
target: `http://localhost:${stencilPort}`,
|
|
||||||
changeOrigin: true,
|
|
||||||
ws: true
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Inject Stencil's dev server iframe into the main entry point
|
|
||||||
app.use(/^\/$/, async (req, res, next) => {
|
|
||||||
let index = await fs.readFile('./docs/index.html', 'utf8');
|
|
||||||
index = index
|
|
||||||
.replace('<head>', '<head><script>window.ShoelaceDevServer = true;</script>')
|
|
||||||
.replace(
|
|
||||||
'</body>',
|
|
||||||
'<iframe src="/~dev-server" style="display: block; width: 0; height: 0; border: 0;"></iframe></body>'
|
|
||||||
);
|
|
||||||
res.type('html').send(index);
|
|
||||||
});
|
|
||||||
app.use('/dist', express.static('./dist'));
|
|
||||||
app.use('/themes', express.static('./themes'));
|
|
||||||
app.use('/', express.static('./docs'));
|
|
||||||
app.listen(proxyPort);
|
|
||||||
|
|
||||||
// Give Stencil's dev server a few seconds to spin up, then launch the browser
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log(chalk.cyan(`\nLaunching the Shoelace dev server at http://localhost:${browserPort}! 🥾\n`));
|
|
||||||
|
|
||||||
bs.init({
|
|
||||||
startPath: '/',
|
|
||||||
port: browserPort,
|
|
||||||
proxy: {
|
|
||||||
target: `http://localhost:${proxyPort}`,
|
|
||||||
ws: true
|
|
||||||
},
|
|
||||||
logLevel: 'silent',
|
|
||||||
notify: false,
|
|
||||||
snippetOptions: {
|
|
||||||
ignorePaths: '/~dev-server'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reload when docs or themes change
|
|
||||||
bs.watch('./{docs,themes}/**/*').on('change', async () => {
|
|
||||||
bs.reload();
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
|
@ -23,7 +23,6 @@
|
||||||
- [Form](/components/form.md)
|
- [Form](/components/form.md)
|
||||||
- [Icon](/components/icon.md)
|
- [Icon](/components/icon.md)
|
||||||
- [Icon Button](/components/icon-button.md)
|
- [Icon Button](/components/icon-button.md)
|
||||||
- [Icon Library](/components/icon-library.md)
|
|
||||||
- [Image Comparer](/components/image-comparer.md)
|
- [Image Comparer](/components/image-comparer.md)
|
||||||
- [Input](/components/input.md)
|
- [Input](/components/input.md)
|
||||||
- [Menu](/components/menu.md)
|
- [Menu](/components/menu.md)
|
||||||
|
@ -55,7 +54,6 @@
|
||||||
- [Include](/components/include.md)
|
- [Include](/components/include.md)
|
||||||
- [Relative Time](/components/relative-time.md)
|
- [Relative Time](/components/relative-time.md)
|
||||||
- [Resize Observer](/components/resize-observer.md)
|
- [Resize Observer](/components/resize-observer.md)
|
||||||
- [Theme](/components/theme.md)
|
|
||||||
|
|
||||||
- Design Tokens
|
- Design Tokens
|
||||||
- [Typography](/tokens/typography.md)
|
- [Typography](/tokens/typography.md)
|
||||||
|
|
|
@ -1,8 +1,18 @@
|
||||||
<p style="margin-top: 0;">
|
<p style="margin-top: 0">
|
||||||
The content in this example was included from <a href="/assets/examples/include.html" target="_blank">a separate file</a>. 🤯
|
The content in this example was included from
|
||||||
|
<a href="/assets/examples/include.html" target="_blank">a separate file</a>. 🤯
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
|
||||||
|
aliqua. Lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi. Fringilla urna porttitor rhoncus dolor purus
|
||||||
|
non enim. Nullam vehicula ipsum a arcu cursus vitae congue mauris. Gravida in fermentum et sollicitudin.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Cursus sit amet dictum sit amet justo donec enim. Sed id semper risus in hendrerit gravida. Viverra accumsan in nisl
|
||||||
|
nisi scelerisque eu ultrices vitae. Et molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit. Nec
|
||||||
|
ullamcorper sit amet risus nullam. Et egestas quis ipsum suspendisse ultrices gravida dictum. Lorem donec massa sapien
|
||||||
|
faucibus et molestie. A cras semper auctor neque vitae.
|
||||||
</p>
|
</p>
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi. Fringilla urna porttitor rhoncus dolor purus non enim. Nullam vehicula ipsum a arcu cursus vitae congue mauris. Gravida in fermentum et sollicitudin.</p>
|
|
||||||
<p>Cursus sit amet dictum sit amet justo donec enim. Sed id semper risus in hendrerit gravida. Viverra accumsan in nisl nisi scelerisque eu ultrices vitae. Et molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit. Nec ullamcorper sit amet risus nullam. Et egestas quis ipsum suspendisse ultrices gravida dictum. Lorem donec massa sapien faucibus et molestie. A cras semper auctor neque vitae.</p>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
console.log('This will only execute if the `allow-scripts` prop is present');
|
console.log('This will only execute if the `allow-scripts` prop is present');
|
||||||
|
|
File diff suppressed because one or more lines are too long
Po Szerokość: | Wysokość: | Rozmiar: 678 KiB |
|
@ -1 +0,0 @@
|
||||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub icon</title><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
|
Przed Szerokość: | Wysokość: | Rozmiar: 848 B |
|
@ -1 +0,0 @@
|
||||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Twitter icon</title><path fill="currentColor" d="M23.954 4.569c-.885.389-1.83.654-2.825.775 1.014-.611 1.794-1.574 2.163-2.723-.951.555-2.005.959-3.127 1.184-.896-.959-2.173-1.559-3.591-1.559-2.717 0-4.92 2.203-4.92 4.917 0 .39.045.765.127 1.124C7.691 8.094 4.066 6.13 1.64 3.161c-.427.722-.666 1.561-.666 2.475 0 1.71.87 3.213 2.188 4.096-.807-.026-1.566-.248-2.228-.616v.061c0 2.385 1.693 4.374 3.946 4.827-.413.111-.849.171-1.296.171-.314 0-.615-.03-.916-.086.631 1.953 2.445 3.377 4.604 3.417-1.68 1.319-3.809 2.105-6.102 2.105-.39 0-.779-.023-1.17-.067 2.189 1.394 4.768 2.209 7.557 2.209 9.054 0 13.999-7.496 13.999-13.986 0-.209 0-.42-.015-.63.961-.689 1.8-1.56 2.46-2.548l-.047-.02z"/></svg>
|
|
Przed Szerokość: | Wysokość: | Rozmiar: 778 B |
|
@ -18,7 +18,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Block the preview while dragging to prevent iframes from intercepting drag events */
|
/* Block the preview while dragging to prevent iframes from intercepting drag events */
|
||||||
.code-block__preview--dragging::after {
|
.code-block__preview--dragging:after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
|
@ -139,7 +139,7 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setWidth = width => preview.style.width = width + 'px';
|
const setWidth = width => (preview.style.width = width + 'px');
|
||||||
|
|
||||||
resizer.addEventListener('mousedown', dragStart);
|
resizer.addEventListener('mousedown', dragStart);
|
||||||
resizer.addEventListener('touchstart', dragStart);
|
resizer.addEventListener('touchstart', dragStart);
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
(() => {
|
(() => {
|
||||||
let metadataStore;
|
let metadataStore;
|
||||||
|
|
||||||
|
function getAttrName(propName) {
|
||||||
|
return propName.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function createPropsTable(props) {
|
function createPropsTable(props) {
|
||||||
const table = document.createElement('table');
|
const table = document.createElement('table');
|
||||||
table.innerHTML = `
|
table.innerHTML = `
|
||||||
|
@ -14,26 +18,30 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${props
|
${props
|
||||||
.map(
|
.map(prop => {
|
||||||
prop => `
|
const attr = getAttrName(prop.name);
|
||||||
<tr>
|
return `
|
||||||
<td>
|
<tr>
|
||||||
<code>${escapeHtml(prop.name)}</code>
|
<td>
|
||||||
${prop.name !== prop.attr && prop.attr !== undefined ? (`
|
<code>${escapeHtml(prop.name)}</code>
|
||||||
<br>
|
${
|
||||||
<small>
|
prop.name !== attr
|
||||||
<sl-tooltip content="Use this name in your HTML">
|
? `
|
||||||
<code class="attribute-tooltip">${escapeHtml(prop.attr)}</code>
|
<br>
|
||||||
</sl-tooltip>
|
<small>
|
||||||
</small>`
|
<sl-tooltip content="This is the attribute name">
|
||||||
) : ''}
|
<code class="attribute-tooltip">${escapeHtml(attr)}</code>
|
||||||
</td>
|
</sl-tooltip>
|
||||||
<td>${escapeHtml(prop.docs)}</td>
|
</small>`
|
||||||
<td><code style="white-space: normal;">${escapeHtml(prop.type)}</code></td>
|
: ''
|
||||||
<td><code style="white-space: normal;">${escapeHtml(prop.default)}</code></td>
|
}
|
||||||
</tr>
|
</td>
|
||||||
`
|
<td>${escapeHtml(prop.description)}</td>
|
||||||
)
|
<td><code style="white-space: normal;">${escapeHtml(prop.type)}</code></td>
|
||||||
|
<td><code style="white-space: normal;">${escapeHtml(prop.defaultValue)}</code></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
})
|
||||||
.join('')}
|
.join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
`;
|
`;
|
||||||
|
@ -48,7 +56,6 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>Event</th>
|
<th>Event</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Type</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -56,9 +63,8 @@
|
||||||
.map(
|
.map(
|
||||||
event => `
|
event => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>${escapeHtml(event.event)}</code></td>
|
<td><code>${escapeHtml(event.name)}</code></td>
|
||||||
<td>${escapeHtml(event.docs)}</td>
|
<td>${escapeHtml(event.description)}</td>
|
||||||
<td><code style="white-space: normal;">CustomEvent<${escapeHtml(event.detail)}></code></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
@ -76,19 +82,29 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>Method</th>
|
<th>Method</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Signature</th>
|
<th>Arguments</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${methods
|
${methods
|
||||||
.map(
|
.map(
|
||||||
method => `
|
method => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>${escapeHtml(method.name)}</code></td>
|
<td><code>${escapeHtml(method.name)}</code></td>
|
||||||
<td>${escapeHtml(method.docs)}</td>
|
<td>${escapeHtml(method.description)}</td>
|
||||||
<td><code style="white-space: normal;">${escapeHtml(method.signature)}</code></td>
|
<td>
|
||||||
</tr>
|
${
|
||||||
`
|
method.params.length
|
||||||
|
? `
|
||||||
|
<code style="white-space: normal;">${escapeHtml(
|
||||||
|
method.params.map(param => `${param.name}: ${param.type}`).join(', ')
|
||||||
|
)}</code>
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -112,7 +128,7 @@
|
||||||
slot => `
|
slot => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>${slot.name ? escapeHtml(slot.name) : '(default)'}</code></td>
|
<td><code>${slot.name ? escapeHtml(slot.name) : '(default)'}</code></td>
|
||||||
<td>${escapeHtml(slot.docs)}</td>
|
<td>${escapeHtml(slot.description)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
@ -138,7 +154,7 @@
|
||||||
style => `
|
style => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>${escapeHtml(style.name)}</code></td>
|
<td><code>${escapeHtml(style.name)}</code></td>
|
||||||
<td>${escapeHtml(style.docs)}</td>
|
<td>${escapeHtml(style.description)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
@ -164,7 +180,7 @@
|
||||||
part => `
|
part => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>${escapeHtml(part.name)}</code></td>
|
<td><code>${escapeHtml(part.name)}</code></td>
|
||||||
<td>${escapeHtml(part.docs)}</td>
|
<td>${escapeHtml(part.description)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
@ -175,22 +191,29 @@
|
||||||
return table.outerHTML;
|
return table.outerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDependenciesList(dependencies, dependencyGraph) {
|
function createDependenciesList(targetComponent, allComponents) {
|
||||||
const all = [...dependencies];
|
|
||||||
const ul = document.createElement('ul');
|
const ul = document.createElement('ul');
|
||||||
|
const dependencies = [];
|
||||||
|
|
||||||
// Gather subdependencies from the dependency graph
|
// Recursively fetch subdependencies
|
||||||
Object.keys(dependencyGraph).map(key => {
|
function getDependencies(tag) {
|
||||||
dependencyGraph[key].map(subdep => {
|
const component = allComponents.find(c => c.tag === tag);
|
||||||
if (!all.includes(subdep)) {
|
if (!component && !Array.isArray(component.dependencies)) {
|
||||||
all.push(subdep);
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
component.dependencies.map(tag => {
|
||||||
|
if (!dependencies.includes(tag)) {
|
||||||
|
dependencies.push(tag);
|
||||||
}
|
}
|
||||||
|
getDependencies(tag);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
all.sort().map(dependency => {
|
getDependencies(targetComponent);
|
||||||
|
dependencies.sort().map(tag => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.innerHTML = `<code>${dependency}</code>`;
|
li.innerHTML = `<code><${tag}></code>`;
|
||||||
ul.appendChild(li);
|
ul.appendChild(li);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -224,48 +247,37 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDocsTagsObject(docsTags) {
|
|
||||||
let tags = {};
|
|
||||||
|
|
||||||
for (const tag of docsTags) {
|
|
||||||
tags[tag.name] = tag.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.$docsify) {
|
if (!window.$docsify) {
|
||||||
throw new Error('Docsify must be loaded before installing this plugin.');
|
throw new Error('Docsify must be loaded before installing this plugin.');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.$docsify.plugins.push((hook, vm) => {
|
window.$docsify.plugins.push((hook, vm) => {
|
||||||
hook.mounted(function () {
|
hook.mounted(function () {
|
||||||
getMetadata()
|
getMetadata().then(metadata => {
|
||||||
.then(metadata => {
|
const target = document.querySelector('.app-name');
|
||||||
const target = document.querySelector('.app-name');
|
|
||||||
|
|
||||||
// Add version
|
// Add version
|
||||||
const version = document.createElement('div');
|
const version = document.createElement('div');
|
||||||
version.classList.add('sidebar-version');
|
version.classList.add('sidebar-version');
|
||||||
version.textContent = metadata.version;
|
version.textContent = metadata.version;
|
||||||
target.appendChild(version);
|
target.appendChild(version);
|
||||||
|
|
||||||
// Add repo buttons
|
// Add repo buttons
|
||||||
const buttons = document.createElement('div');
|
const buttons = document.createElement('div');
|
||||||
buttons.classList.add('sidebar-buttons');
|
buttons.classList.add('sidebar-buttons');
|
||||||
buttons.innerHTML = `
|
buttons.innerHTML = `
|
||||||
<a class="repo-button repo-button--small repo-button--sponsor" href="https://github.com/sponsors/claviska" rel="noopener" target="_blank">
|
<a class="repo-button repo-button--small repo-button--sponsor" href="https://github.com/sponsors/claviska" rel="noopener" target="_blank">
|
||||||
<sl-icon name="heart"></sl-icon> Sponsor
|
<sl-icon name="heart"></sl-icon> Sponsor
|
||||||
</a>
|
</a>
|
||||||
<a class="repo-button repo-button--small repo-button--github" href="https://github.com/shoelace-style/shoelace/stargazers" rel="noopener" target="_blank">
|
<a class="repo-button repo-button--small repo-button--github" href="https://github.com/shoelace-style/shoelace/stargazers" rel="noopener" target="_blank">
|
||||||
<sl-icon src="/assets/images/github.svg"></sl-icon> <span class="github-star-count">Star</span>
|
<sl-icon name="github"></sl-icon> <span class="github-star-count">Star</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="repo-button repo-button--small repo-button--twitter" href="https://twitter.com/shoelace_style" rel="noopener" target="_blank">
|
<a class="repo-button repo-button--small repo-button--twitter" href="https://twitter.com/shoelace_style" rel="noopener" target="_blank">
|
||||||
<sl-icon src="/assets/images/twitter.svg"></sl-icon> Follow
|
<sl-icon name="twitter"></sl-icon> Follow
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
target.appendChild(buttons);
|
target.appendChild(buttons);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
hook.beforeEach(async function (content, next) {
|
hook.beforeEach(async function (content, next) {
|
||||||
|
@ -276,39 +288,33 @@
|
||||||
|
|
||||||
// Handle [component-header] tags
|
// Handle [component-header] tags
|
||||||
content = content.replace(/\[component-header:([a-z-]+)\]/g, (match, tag) => {
|
content = content.replace(/\[component-header:([a-z-]+)\]/g, (match, tag) => {
|
||||||
const data = metadata.components.filter(data => data.tag === tag)[0];
|
const component = metadata.components.filter(data => data.tag === tag)[0];
|
||||||
let result = '';
|
let result = '';
|
||||||
|
|
||||||
if (!data) {
|
if (!component) {
|
||||||
console.error('Component not found in metadata: ' + tag);
|
console.error('Component not found in metadata: ' + tag);
|
||||||
next(content);
|
next(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = getDocsTagsObject(data.docsTags);
|
|
||||||
if (!tags) {
|
|
||||||
console.error(`No metadata tags found for ${tag}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let badgeType = 'info';
|
let badgeType = 'info';
|
||||||
if (tags.status === 'stable') badgeType = 'primary';
|
if (component.status === 'stable') badgeType = 'primary';
|
||||||
if (tags.status === 'experimental') badgeType = 'warning';
|
if (component.status === 'experimental') badgeType = 'warning';
|
||||||
if (tags.status === 'planned') badgeType = 'info';
|
if (component.status === 'planned') badgeType = 'info';
|
||||||
if (tags.status === 'deprecated') badgeType = 'danger';
|
if (component.status === 'deprecated') badgeType = 'danger';
|
||||||
|
|
||||||
result += `
|
result += `
|
||||||
<div class="component-header">
|
<div class="component-header">
|
||||||
<div class="component-header__tag">
|
<div class="component-header__tag">
|
||||||
<code><${tag}></code>
|
<code>${component.className} | <${component.tag}></code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="component-header__info">
|
<div class="component-header__info">
|
||||||
<sl-badge type="info" pill>
|
<sl-badge type="info" pill>
|
||||||
Since ${tags.since || '?'}
|
Since ${component.since || '?'}
|
||||||
</sl-badge>
|
</sl-badge>
|
||||||
|
|
||||||
<sl-badge type="${badgeType}" pill style="text-transform: capitalize;">
|
<sl-badge type="${badgeType}" pill style="text-transform: capitalize;">
|
||||||
${tags.status}
|
${component.status}
|
||||||
</sl-badge>
|
</sl-badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -319,64 +325,64 @@
|
||||||
|
|
||||||
// Handle [component-metadata] tags
|
// Handle [component-metadata] tags
|
||||||
content = content.replace(/\[component-metadata:([a-z-]+)\]/g, (match, tag) => {
|
content = content.replace(/\[component-metadata:([a-z-]+)\]/g, (match, tag) => {
|
||||||
const data = metadata.components.filter(data => data.tag === tag)[0];
|
const component = metadata.components.filter(data => data.tag === tag)[0];
|
||||||
let result = '';
|
let result = '';
|
||||||
|
|
||||||
if (!data) {
|
if (!component) {
|
||||||
console.error('Component not found in metadata: ' + tag);
|
console.error('Component not found in metadata: ' + tag);
|
||||||
next(content);
|
next(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.props.length) {
|
if (component.props.length) {
|
||||||
result += `
|
result += `
|
||||||
## Properties
|
## Properties
|
||||||
${createPropsTable(data.props)}
|
${createPropsTable(component.props)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.events.length) {
|
if (component.events.length) {
|
||||||
result += `
|
result += `
|
||||||
## Events
|
## Events
|
||||||
${createEventsTable(data.events)}
|
${createEventsTable(component.events)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.methods.length) {
|
if (component.methods.length) {
|
||||||
result += `
|
result += `
|
||||||
## Methods
|
## Methods
|
||||||
${createMethodsTable(data.methods)}
|
${createMethodsTable(component.methods)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.slots.length) {
|
if (component.slots.length) {
|
||||||
result += `
|
result += `
|
||||||
## Slots
|
## Slots
|
||||||
${createSlotsTable(data.slots)}
|
${createSlotsTable(component.slots)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.styles.length) {
|
if (component.cssCustomProperties.length) {
|
||||||
result += `
|
result += `
|
||||||
## CSS Custom Properties
|
## CSS Custom Properties
|
||||||
${createCustomPropertiesTable(data.styles)}
|
${createCustomPropertiesTable(component.cssCustomProperties)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.parts.length) {
|
if (component.parts.length) {
|
||||||
result += `
|
result += `
|
||||||
## CSS Parts
|
## CSS Parts
|
||||||
${createPartsTable(data.parts)}
|
${createPartsTable(component.parts)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.dependencies.length) {
|
if (component.dependencies.length) {
|
||||||
result += `
|
result += `
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
This component has the following dependencies. If you're not using the lazy loader, be sure to import and
|
This component has the following dependencies so, if you're [cherry picking](/getting-started/installation#cherry-picking),
|
||||||
register these components in addition to <code>${tag}</code>.
|
be sure to import these components in addition to <code><${tag}></code>.
|
||||||
|
|
||||||
${createDependenciesList(data.dependencies, data.dependencyGraph)}
|
${createDependenciesList(component.tag, metadata.components)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
(() => {
|
||||||
|
if (!window.$docsify) {
|
||||||
|
throw new Error('Docsify must be loaded before installing this plugin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Docsify generates pages dynamically and asynchronously, so when a reload happens, the scroll position can't be
|
||||||
|
// be restored immediately. This plugin waits until Docsify loads the page and then restores it.
|
||||||
|
//
|
||||||
|
window.$docsify.plugins.push((hook, vm) => {
|
||||||
|
hook.ready(() => {
|
||||||
|
// Restore
|
||||||
|
const scrollTop = sessionStorage.getItem('bs-scroll');
|
||||||
|
if (scrollTop) {
|
||||||
|
document.documentElement.scrollTop = scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember
|
||||||
|
document.addEventListener('scroll', event => {
|
||||||
|
sessionStorage.setItem('bs-scroll', document.documentElement.scrollTop);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
|
@ -8,7 +8,7 @@
|
||||||
// Move search below the app name
|
// Move search below the app name
|
||||||
const appName = document.querySelector('.sidebar .app-name');
|
const appName = document.querySelector('.sidebar .app-name');
|
||||||
const search = document.querySelector('.sidebar .search');
|
const search = document.querySelector('.sidebar .search');
|
||||||
appName.insertAdjacentElement("afterend", search);
|
appName.insertAdjacentElement('afterend', search);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: .5rem;
|
top: 0.5rem;
|
||||||
right: .5rem;
|
right: 0.5rem;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
top: .25rem;
|
top: 0.25rem;
|
||||||
right: .25rem;
|
right: 0.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition-demo::after {
|
.transition-demo:after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background-color: var(--sl-color-primary-500);
|
background-color: var(--sl-color-primary-500);
|
||||||
|
@ -33,7 +33,7 @@
|
||||||
transition-property: width;
|
transition-property: width;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition-demo:hover::after {
|
.transition-demo:hover:after {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -92,11 +92,11 @@
|
||||||
/* Tips & warnings */
|
/* Tips & warnings */
|
||||||
.sl-theme-dark .markdown-section p.tip,
|
.sl-theme-dark .markdown-section p.tip,
|
||||||
.sl-theme-dark .markdown-section p.warn {
|
.sl-theme-dark .markdown-section p.warn {
|
||||||
background-color: var(--sl-color-gray-950)
|
background-color: var(--sl-color-gray-950);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sl-theme-dark .markdown-section p.tip::before,
|
.sl-theme-dark .markdown-section p.tip:before,
|
||||||
.sl-theme-dark .markdown-section p.warn::before {
|
.sl-theme-dark .markdown-section p.warn:before {
|
||||||
color: var(--sl-color-gray-900);
|
color: var(--sl-color-gray-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,7 +112,7 @@
|
||||||
|
|
||||||
/* Code blocks */
|
/* Code blocks */
|
||||||
.sl-theme-dark .markdown-section pre,
|
.sl-theme-dark .markdown-section pre,
|
||||||
.sl-theme-dark .code-block__source {
|
.sl-theme-dark .code-block__source {
|
||||||
background-color: var(--sl-color-gray-800);
|
background-color: var(--sl-color-gray-800);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -175,7 +175,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Repo buttons */
|
/* Repo buttons */
|
||||||
.sl-theme-dark .repo-button {
|
.sl-theme-dark .repo-button {
|
||||||
background-color: var(--sl-color-gray-900);
|
background-color: var(--sl-color-gray-900);
|
||||||
border-color: var(--sl-color-gray-800);
|
border-color: var(--sl-color-gray-800);
|
||||||
color: var(--sl-color-gray-200);
|
color: var(--sl-color-gray-200);
|
||||||
|
@ -185,12 +185,11 @@
|
||||||
color: var(--sl-color-white);
|
color: var(--sl-color-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sl-theme-dark .repo-button:hover {
|
.sl-theme-dark .repo-button:hover {
|
||||||
background-color: var(--sl-color-gray-900);
|
background-color: var(--sl-color-gray-900);
|
||||||
border: solid 1px var(--sl-color-gray-700);
|
border: solid 1px var(--sl-color-gray-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sl-theme-dark .repo-button:focus {
|
.sl-theme-dark .repo-button:focus {
|
||||||
border-color: var(--sl-color-primary-500);
|
border-color: var(--sl-color-primary-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,9 @@ html {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *:before, *:after {
|
*,
|
||||||
|
*:before,
|
||||||
|
*:after {
|
||||||
box-sizing: inherit;
|
box-sizing: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,7 +42,7 @@ strong {
|
||||||
color: var(--sl-color-gray-400);
|
color: var(--sl-color-gray-400);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding: 0 var(--sl-spacing-small);
|
padding: 0 var(--sl-spacing-small);
|
||||||
margin: -1.25rem 0 .6rem 0;
|
margin: -1.25rem 0 0.6rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-buttons {
|
.sidebar-buttons {
|
||||||
|
@ -76,7 +78,7 @@ strong {
|
||||||
.sidebar .input-wrap {
|
.sidebar .input-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 .25rem;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .clear-button {
|
.sidebar .clear-button {
|
||||||
|
@ -88,7 +90,7 @@ strong {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .clear-button svg {
|
.sidebar .clear-button svg {
|
||||||
transform: scale(.75) !important;
|
transform: scale(0.75) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .clear-button:focus {
|
.sidebar .clear-button:focus {
|
||||||
|
@ -101,13 +103,13 @@ strong {
|
||||||
|
|
||||||
.search .matching-post {
|
.search .matching-post {
|
||||||
border-bottom: solid 1px var(--sl-color-gray-500) !important;
|
border-bottom: solid 1px var(--sl-color-gray-500) !important;
|
||||||
padding: .25rem 1.5rem;
|
padding: 0.25rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search .matching-post a {
|
.search .matching-post a {
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: inherit
|
border-radius: inherit;
|
||||||
padding: .5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search .matching-post h2 {
|
.search .matching-post h2 {
|
||||||
|
@ -120,13 +122,13 @@ strong {
|
||||||
|
|
||||||
/* Sidebar toggle */
|
/* Sidebar toggle */
|
||||||
.sidebar-toggle {
|
.sidebar-toggle {
|
||||||
top: .25rem;
|
top: 0.25rem;
|
||||||
left: .25rem;
|
left: 0.25rem;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
border-radius: var(--sl-border-radius-medium);
|
border-radius: var(--sl-border-radius-medium);
|
||||||
background-color: var(--sl-color-white);
|
background-color: var(--sl-color-white);
|
||||||
padding: .5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-toggle:focus {
|
.sidebar-toggle:focus {
|
||||||
|
@ -142,7 +144,7 @@ strong {
|
||||||
body.close .sidebar-toggle {
|
body.close .sidebar-toggle {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
background: none;
|
background: none;
|
||||||
padding: .5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,8 +162,8 @@ strong {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
line-height: 1.5em;
|
line-height: 1.5em;
|
||||||
padding-top: .25em;
|
padding-top: 0.25em;
|
||||||
padding-bottom: .25em;
|
padding-bottom: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-nav li.collapse > a,
|
.sidebar-nav li.collapse > a,
|
||||||
|
@ -172,20 +174,19 @@ strong {
|
||||||
.sidebar li > p {
|
.sidebar li > p {
|
||||||
font-weight: var(--sl-font-weight-bold);
|
font-weight: var(--sl-font-weight-bold);
|
||||||
border-bottom: solid 1px var(--sl-color-gray-200);
|
border-bottom: solid 1px var(--sl-color-gray-200);
|
||||||
margin: 0 .75rem .5rem 0;
|
margin: 0 0.75rem 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar ul li ul {
|
.sidebar ul li ul {
|
||||||
padding-left: .5rem;
|
padding-left: 0.5rem;
|
||||||
margin: 0 .75rem 1.5rem 0;
|
margin: 0 0.75rem 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar ul ul ul {
|
.sidebar ul ul ul {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 0 0 .5rem;
|
margin: 0 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.sidebar ul ul ul li {
|
.sidebar ul ul ul li {
|
||||||
list-style: disc;
|
list-style: disc;
|
||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
|
@ -375,7 +376,7 @@ strong {
|
||||||
}
|
}
|
||||||
|
|
||||||
.namespace {
|
.namespace {
|
||||||
opacity: .7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-section pre .token.property,
|
.markdown-section pre .token.property,
|
||||||
|
@ -559,14 +560,15 @@ strong {
|
||||||
|
|
||||||
/* Repo buttons */
|
/* Repo buttons */
|
||||||
html .repo-button {
|
html .repo-button {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
vertical-align: middle;
|
align-items: center;
|
||||||
background-color: var(--sl-color-white);
|
background-color: var(--sl-color-white);
|
||||||
border: solid 1px var(--sl-color-gray-200);
|
border: solid 1px var(--sl-color-gray-200);
|
||||||
border-radius: var(--sl-border-radius-medium);
|
border-radius: var(--sl-border-radius-medium);
|
||||||
box-shadow: var(--sl-shadow-x-small);
|
box-shadow: var(--sl-shadow-x-small);
|
||||||
font-size: var(--sl-font-size-small);
|
font-size: var(--sl-font-size-small);
|
||||||
font-weight: var(--sl-font-weight-semibold);
|
font-weight: var(--sl-font-weight-semibold);
|
||||||
|
line-height: 2;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--sl-color-gray-700);
|
color: var(--sl-color-gray-700);
|
||||||
padding: var(--sl-spacing-xx-small) var(--sl-spacing-small);
|
padding: var(--sl-spacing-xx-small) var(--sl-spacing-small);
|
||||||
|
@ -587,14 +589,13 @@ html .repo-button:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
html .repo-button:not(:last-of-type) {
|
html .repo-button:not(:last-of-type) {
|
||||||
margin-right: .125rem;
|
margin-right: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
html .repo-button sl-icon {
|
html .repo-button sl-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -1px;
|
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-right: 0.125rem;
|
margin-right: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
html .repo-button--small {
|
html .repo-button--small {
|
||||||
|
@ -614,7 +615,7 @@ html .repo-button--twitter sl-icon {
|
||||||
color: #1ea0f2;
|
color: #1ea0f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-page^="tokens/"] .table-wrapper td:first-child,
|
body[data-page^='tokens/'] .table-wrapper td:first-child,
|
||||||
body[data-page^="tokens/"] .table-wrapper td:first-child code {
|
body[data-page^='tokens/'] .table-wrapper td:first-child code {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,32 +46,32 @@ This example demonstrates all of the baked-in animations and easings. Animations
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
|
import { getAnimationNames, getEasingNames } from '/dist/shoelace.js';
|
||||||
|
|
||||||
const container = document.querySelector('.animation-sandbox');
|
const container = document.querySelector('.animation-sandbox');
|
||||||
const animation = container.querySelector('sl-animation');
|
const animation = container.querySelector('sl-animation');
|
||||||
const animationName = container.querySelector('.controls sl-select:nth-child(1)');
|
const animationName = container.querySelector('.controls sl-select:nth-child(1)');
|
||||||
const easingName = container.querySelector('.controls sl-select:nth-child(2)');
|
const easingName = container.querySelector('.controls sl-select:nth-child(2)');
|
||||||
const playbackRate = container.querySelector('sl-range');
|
const playbackRate = container.querySelector('sl-range');
|
||||||
|
const animations = getAnimationNames();
|
||||||
|
const easings = getEasingNames();
|
||||||
|
|
||||||
animation.getAnimationNames().then(names => {
|
animations.map(name => {
|
||||||
names.map(name => {
|
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
|
||||||
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
|
textContent: name,
|
||||||
textContent: name,
|
value: name
|
||||||
value: name
|
|
||||||
});
|
|
||||||
animationName.appendChild(menuItem);
|
|
||||||
});
|
});
|
||||||
|
animationName.appendChild(menuItem);
|
||||||
});
|
});
|
||||||
|
|
||||||
animation.getEasingNames().then(names => {
|
easings.map(name => {
|
||||||
names.map(name => {
|
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
|
||||||
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
|
textContent: name,
|
||||||
textContent: name,
|
value: name
|
||||||
value: name
|
|
||||||
});
|
|
||||||
easingName.appendChild(menuItem);
|
|
||||||
});
|
});
|
||||||
});
|
easingName.appendChild(menuItem);
|
||||||
|
});
|
||||||
|
|
||||||
animationName.addEventListener('sl-change', () => animation.name = animationName.value);
|
animationName.addEventListener('sl-change', () => animation.name = animationName.value);
|
||||||
easingName.addEventListener('sl-change', () => animation.easing = easingName.value);
|
easingName.addEventListener('sl-change', () => animation.easing = easingName.value);
|
||||||
|
|
|
@ -82,8 +82,8 @@ When including badges in menu items, use the `suffix` slot to make sure they're
|
||||||
```html preview
|
```html preview
|
||||||
<sl-menu style="max-width: 240px; border: solid 1px var(--sl-panel-border-color); border-radius: var(--sl-border-radius-medium);">
|
<sl-menu style="max-width: 240px; border: solid 1px var(--sl-panel-border-color); border-radius: var(--sl-border-radius-medium);">
|
||||||
<sl-menu-label>Messages</sl-menu-label>
|
<sl-menu-label>Messages</sl-menu-label>
|
||||||
<sl-menu-item>Comments <sl-badge slot="suffix" pill>4</sl-badge></sl-menu-item>
|
<sl-menu-item>Comments <sl-badge slot="suffix" type="info" pill>4</sl-badge></sl-menu-item>
|
||||||
<sl-menu-item>Replies <sl-badge slot="suffix" pill>12</sl-badge></sl-menu-item>
|
<sl-menu-item>Replies <sl-badge slot="suffix" type="info" pill>12</sl-badge></sl-menu-item>
|
||||||
</sl-menu>
|
</sl-menu>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -1,396 +0,0 @@
|
||||||
# Icon Library
|
|
||||||
|
|
||||||
[component-header:sl-icon-library]
|
|
||||||
|
|
||||||
Icon libraries let you register additional icons to use with the `<sl-icon>` component.
|
|
||||||
|
|
||||||
An icon library is a renderless component that registers a custom set of SVG icons. The icon files can exist locally or on a CORS-enabled endpoint (i.e. a CDN). There is no limit to how many icon libraries you can register and there is no cost associated with registering them, as individual icons are only requested when they're used.
|
|
||||||
|
|
||||||
To register an icon library, create an `<sl-icon-library>` element with a name and resolver function. The resolver function translates an icon name to a URL where its corresponding SVG file exists. Refer to the examples below to better understand how it works.
|
|
||||||
|
|
||||||
If necessary, a mutator function can be used to mutate the SVG element before rendering. This is necessary for some libraries due to the many possible ways SVGs are crafted. For example, icons should inherit the current text color via `currentColor`, so you may need to apply `fill="currentColor` or `stroke="currentColor"` to the SVG element using this function.
|
|
||||||
|
|
||||||
Here's an example that registers an icon library located in the `/assets/icons` directory.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Create a library named "my-icons" -->
|
|
||||||
<sl-icon-library name="my-icons"></sl-icon>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Get a reference to the library element
|
|
||||||
const library = document.querySelector('sl-icon-library[name="my-icons"]');
|
|
||||||
|
|
||||||
// Add a resolver function to translate icon names to URLs
|
|
||||||
library.resolver = name => `/assets/icons/${name}.svg`;
|
|
||||||
|
|
||||||
// Apply an optional mutator function to modify the SVG before it renders
|
|
||||||
library.mutator = svg => svg.setAttribute('fill', 'currentColor');
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
To display an icon, set the `library` and `name` attributes of an `<sl-icon>` element.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- This will show the icon located at /assets/icons/smile.svg -->
|
|
||||||
<sl-icon library="my-icons" name="smile"></sl-icon>
|
|
||||||
```
|
|
||||||
|
|
||||||
The location of the icon library in the DOM doesn't matter as long as it's within the `<body>` element. If an icon is used before registration, it will be empty until registration has completed. It's perfectly acceptable to place all `<sl-icon-library>` elements before the `</body>` tag if you prefer to organize them that way.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
The following examples demonstrate how to register a number of popular, open source icon libraries via CDN. Feel free to adapt the code as you see fit to use your own origin or naming conventions.
|
|
||||||
|
|
||||||
### Boxicons
|
|
||||||
|
|
||||||
This will register the [Boxicons](https://boxicons.com/) library using the jsDelivr CDN. This library has three variations: regular (`bx-*`), solid (`bxs-*`), and logos (`bxl-*`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
|
||||||
|
|
||||||
Icons in this library are licensed under the [Creative Commons 4.0 License](https://github.com/atisawd/boxicons#license).
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<sl-icon-library name="boxicons"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="boxicons"]');
|
|
||||||
library.resolver = name => {
|
|
||||||
let folder = 'regular';
|
|
||||||
if (name.substring(0, 4) === 'bxs-') folder = 'solid';
|
|
||||||
if (name.substring(0, 4) === 'bxl-') folder = 'logos';
|
|
||||||
return `https://cdn.jsdelivr.net/npm/boxicons@2.0.5/svg/${folder}/${name}.svg`;
|
|
||||||
};
|
|
||||||
library.mutator = svg => svg.setAttribute('fill', 'currentColor');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="font-size: 24px;">
|
|
||||||
<sl-icon library="boxicons" name="bx-bot"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bx-cookie"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bx-joystick"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bx-save"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bx-server"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bx-wine"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="boxicons" name="bxs-bot"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxs-cookie"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxs-joystick"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxs-save"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxs-server"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxs-wine"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="boxicons" name="bxl-apple"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxl-chrome"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxl-edge"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxl-firefox"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxl-opera"></sl-icon>
|
|
||||||
<sl-icon library="boxicons" name="bxl-microsoft"></sl-icon>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Feather Icons
|
|
||||||
|
|
||||||
This will register the [Feather Icons](https://feathericons.com/) library using the jsDelivr CDN.
|
|
||||||
|
|
||||||
Icons in this library are licensed under the [MIT License](https://github.com/feathericons/feather/blob/master/LICENSE).
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<sl-icon-library name="feather"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="feather"]');
|
|
||||||
library.resolver = name => `https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/icons/${name}.svg`;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="font-size: 24px;">
|
|
||||||
<sl-icon library="feather" name="feather"></sl-icon>
|
|
||||||
<sl-icon library="feather" name="pie-chart"></sl-icon>
|
|
||||||
<sl-icon library="feather" name="settings"></sl-icon>
|
|
||||||
<sl-icon library="feather" name="map-pin"></sl-icon>
|
|
||||||
<sl-icon library="feather" name="printer"></sl-icon>
|
|
||||||
<sl-icon library="feather" name="shopping-cart"></sl-icon>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Font Awesome
|
|
||||||
|
|
||||||
This will register the [Font Awesome Free](https://fontawesome.com/) library using the jsDelivr CDN. This library has three variations: regular (`far-*`), solid (`fas-*`), and brands (`fab-*`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
|
||||||
|
|
||||||
Icons in this library are licensed under the [Font Awesome Free License](https://github.com/FortAwesome/Font-Awesome/blob/master/LICENSE.txt). Some of the icons that appear on the Font Awesome website require a license and are therefore not available in the CDN.
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<sl-icon-library name="fa"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="fa"]');
|
|
||||||
library.resolver = name => {
|
|
||||||
const filename = name.replace(/^fa[rbs]-/, '');
|
|
||||||
let folder = 'regular';
|
|
||||||
if (name.substring(0, 4) === 'fas-') folder = 'solid';
|
|
||||||
if (name.substring(0, 4) === 'fab-') folder = 'brands';
|
|
||||||
return `https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.15.1/svgs/${folder}/${filename}.svg`;
|
|
||||||
};
|
|
||||||
library.mutator = svg => svg.setAttribute('fill', 'currentColor');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="font-size: 24px;">
|
|
||||||
<sl-icon library="fa" name="far-bell"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="far-comment"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="far-hand-point-right"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="far-hdd"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="far-heart"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="far-star"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="fa" name="fas-archive"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fas-book"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fas-chess-knight"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fas-dice"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fas-pizza-slice"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fas-scroll"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="fa" name="fab-apple"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fab-chrome"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fab-edge"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fab-firefox"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fab-opera"></sl-icon>
|
|
||||||
<sl-icon library="fa" name="fab-microsoft"></sl-icon>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Heroicons
|
|
||||||
|
|
||||||
This will register the [Heroicons](https://heroicons.com/) library using the jsDelivr CDN.
|
|
||||||
|
|
||||||
Icons in this library are licensed under the [MIT License](https://github.com/tailwindlabs/heroicons/blob/master/LICENSE).
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<sl-icon-library name="heroicons"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="heroicons"]');
|
|
||||||
library.resolver = name => `https://cdn.jsdelivr.net/npm/heroicons@0.4.2/outline/${name}.svg`;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="font-size: 24px;">
|
|
||||||
<sl-icon library="heroicons" name="chat"></sl-icon>
|
|
||||||
<sl-icon library="heroicons" name="cloud"></sl-icon>
|
|
||||||
<sl-icon library="heroicons" name="cog"></sl-icon>
|
|
||||||
<sl-icon library="heroicons" name="document-text"></sl-icon>
|
|
||||||
<sl-icon library="heroicons" name="gift"></sl-icon>
|
|
||||||
<sl-icon library="heroicons" name="volume-up"></sl-icon>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ionicons
|
|
||||||
|
|
||||||
This will register the [Ionicons](https://ionicons.com/) library using the jsDelivr CDN. This library has three variations: outline (default), filled (`*-filled`), and sharp (`*-sharp`). A mutator function is required to polyfill a handful of styles we're not including.
|
|
||||||
|
|
||||||
Icons in this library are licensed under the [MIT License](https://github.com/ionic-team/ionicons/blob/master/LICENSE).
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<sl-icon-library name="ionicons"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="ionicons"]');
|
|
||||||
library.resolver = name => `https://cdn.jsdelivr.net/npm/ionicons@5.1.2/dist/ionicons/svg/${name}.svg`;
|
|
||||||
library.mutator = svg => {
|
|
||||||
svg.setAttribute('fill', 'currentColor');
|
|
||||||
svg.setAttribute('stroke', 'currentColor');
|
|
||||||
[...svg.querySelectorAll('.ionicon-fill-none')].map(el => el.setAttribute('fill', 'none'));
|
|
||||||
[...svg.querySelectorAll('.ionicon-stroke-width')].map(el => el.setAttribute('stroke-width', '32px'));
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="font-size: 24px;">
|
|
||||||
<sl-icon library="ionicons" name="alarm"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="american-football"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="bug"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="chatbubble"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="settings"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="warning"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="ionicons" name="alarm-outline"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="american-football-outline"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="bug-outline"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="chatbubble-outline"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="settings-outline"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="warning-outline"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="ionicons" name="alarm-sharp"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="american-football-sharp"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="bug-sharp"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="chatbubble-sharp"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="settings-sharp"></sl-icon>
|
|
||||||
<sl-icon library="ionicons" name="warning-sharp"></sl-icon>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Jam Icons
|
|
||||||
|
|
||||||
This will register the [Jam Icons](https://jam-icons.com/) library using the jsDelivr CDN. This library has two variations: regular (default) and filled (`*-f`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
|
||||||
|
|
||||||
Icons in this library are licensed under the [MIT License](https://github.com/michaelampr/jam/blob/master/LICENSE).
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<sl-icon-library name="jam"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="jam"]');
|
|
||||||
library.resolver = name => `https://cdn.jsdelivr.net/npm/jam-icons@2.0.0/svg/${name}.svg`;
|
|
||||||
library.mutator = svg => svg.setAttribute('fill', 'currentColor');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="font-size: 24px;">
|
|
||||||
<sl-icon library="jam" name="calendar"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="camera"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="filter"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="leaf"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="picture"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="set-square"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="jam" name="calendar-f"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="camera-f"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="filter-f"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="leaf-f"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="picture-f"></sl-icon>
|
|
||||||
<sl-icon library="jam" name="set-square-f"></sl-icon>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Material Icons
|
|
||||||
|
|
||||||
This will register the [Material Icons](https://material.io/resources/icons/?style=baseline) library using the jsDelivr CDN. This library has three variations: outline (default), round (`*_round`), and sharp (`*_sharp`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
|
||||||
|
|
||||||
Icons in this library are licensed under the [Apache 2.0 License](https://github.com/google/material-design-icons/blob/master/LICENSE).
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<sl-icon-library name="material"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="material"]');
|
|
||||||
library.resolver = name => {
|
|
||||||
const match = name.match(/^(.*?)(_(round|sharp))?$/);
|
|
||||||
return `https://cdn.jsdelivr.net/npm/@material-icons/svg@1.0.5/svg/${match[1]}/${match[3] || 'outline'}.svg`;
|
|
||||||
};
|
|
||||||
library.mutator = svg => svg.setAttribute('fill', 'currentColor');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="font-size: 24px;">
|
|
||||||
<sl-icon library="material" name="notifications"></sl-icon>
|
|
||||||
<sl-icon library="material" name="email"></sl-icon>
|
|
||||||
<sl-icon library="material" name="delete"></sl-icon>
|
|
||||||
<sl-icon library="material" name="volume_up"></sl-icon>
|
|
||||||
<sl-icon library="material" name="settings"></sl-icon>
|
|
||||||
<sl-icon library="material" name="shopping_basket"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="material" name="notifications_round"></sl-icon>
|
|
||||||
<sl-icon library="material" name="email_round"></sl-icon>
|
|
||||||
<sl-icon library="material" name="delete_round"></sl-icon>
|
|
||||||
<sl-icon library="material" name="volume_up_round"></sl-icon>
|
|
||||||
<sl-icon library="material" name="settings_round"></sl-icon>
|
|
||||||
<sl-icon library="material" name="shopping_basket_round"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="material" name="notifications_sharp"></sl-icon>
|
|
||||||
<sl-icon library="material" name="email_sharp"></sl-icon>
|
|
||||||
<sl-icon library="material" name="delete_sharp"></sl-icon>
|
|
||||||
<sl-icon library="material" name="volume_up_sharp"></sl-icon>
|
|
||||||
<sl-icon library="material" name="settings_sharp"></sl-icon>
|
|
||||||
<sl-icon library="material" name="shopping_basket_sharp"></sl-icon>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Remix Icon
|
|
||||||
|
|
||||||
This will register the [Remix Icon](https://remixicon.com/) library using the jsDelivr CDN. This library has two variations: line (default) and fill (`*-fill`). It also groups icons by categories, so the name must include the category and icon separated by a slash. A mutator function is required to set the SVG's `fill` to `currentColor`.
|
|
||||||
|
|
||||||
Icons in this library are licensed under the [Apache 2.0 License](https://github.com/Remix-Design/RemixIcon/blob/master/License).
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<sl-icon-library name="remixicon"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="remixicon"]');
|
|
||||||
library.resolver = name => {
|
|
||||||
const match = name.match(/^(.*?)\/(.*?)(-(fill))?$/);
|
|
||||||
match[1] = match[1].charAt(0).toUpperCase() + match[1].slice(1);
|
|
||||||
return `https://cdn.jsdelivr.net/npm/remixicon@2.5.0/icons/${match[1]}/${match[2]}${match[3] || '-line'}.svg`;
|
|
||||||
};
|
|
||||||
library.mutator = svg => svg.setAttribute('fill', 'currentColor');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="font-size: 24px;">
|
|
||||||
<sl-icon library="remixicon" name="business/cloud"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="design/brush"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="business/pie-chart"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="development/bug"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="media/image"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="system/alert"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="remixicon" name="business/cloud-fill"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="design/brush-fill"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="business/pie-chart-fill"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="development/bug-fill"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="media/image-fill"></sl-icon>
|
|
||||||
<sl-icon library="remixicon" name="system/alert-fill"></sl-icon>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Unicons
|
|
||||||
|
|
||||||
This will register the [Unicons](https://iconscout.com/unicons) library using the jsDelivr CDN. This library has two variations: line (default) and solid (`*-s`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
|
||||||
|
|
||||||
Icons in this library are licensed under the [Apache 2.0 License](https://github.com/Iconscout/unicons/blob/master/LICENSE). Some of the icons that appear on the Unicons website, particularly many of the solid variations, require a license and are therefore not available in the CDN.
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<sl-icon-library name="unicons"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="unicons"]');
|
|
||||||
library.resolver = name => {
|
|
||||||
const match = name.match(/^(.*?)(-s)?$/);
|
|
||||||
return `https://cdn.jsdelivr.net/npm/@iconscout/unicons@3.0.3/svg/${match[2] === '-s' ? 'solid' : 'line'}/${match[1]}.svg`;
|
|
||||||
};
|
|
||||||
library.mutator = svg => svg.setAttribute('fill', 'currentColor');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="font-size: 24px;">
|
|
||||||
<sl-icon library="unicons" name="clock"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="graph-bar"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="padlock"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="polygon"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="rocket"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="star"></sl-icon>
|
|
||||||
<br>
|
|
||||||
<sl-icon library="unicons" name="clock-s"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="graph-bar-s"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="padlock-s"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="polygon-s"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="rocket-s"></sl-icon>
|
|
||||||
<sl-icon library="unicons" name="star-s"></sl-icon>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Customizing the Default Library
|
|
||||||
|
|
||||||
Shoelace comes bundled with over 1,200 icons courtesy of the [Bootstrap Icons](https://icons.getbootstrap.com/) project. These are the default icons that display when you use `<sl-icon>` without a `name` attribute. If you prefer to have these icons resolve elsewhere, you can register an icon library with the `default` name and a custom resolver.
|
|
||||||
|
|
||||||
This example will load the same set of icons from the jsDelivr CDN instead of your local assets folder.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<sl-icon-library name="default"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="default"]');
|
|
||||||
library.resolver = name => `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.0.0/icons/${name}.svg`;
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, you can replace the default icons with a completely different icon set.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<sl-icon-library name="default"></sl-icon-library>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const library = document.querySelector('sl-icon-library[name="default"]');
|
|
||||||
library.resolver = name => `/my/custom/icons/${name}.svg`;
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
[component-metadata:sl-icon-library]
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
Icons are symbols that can be used to represent various options within an application.
|
Icons are symbols that can be used to represent various options within an application.
|
||||||
|
|
||||||
Shoelace comes bundled with over 1,200 icons courtesy of the [Bootstrap Icons](https://icons.getbootstrap.com/) project. If you prefer, you can also register [custom icon libraries](/components/icon-library.md).
|
Shoelace comes bundled with over 1,300 icons courtesy of the [Bootstrap Icons](https://icons.getbootstrap.com/) project. If you prefer, you can also register a [custom icon library](#icon-libraries).
|
||||||
|
|
||||||
Click or tap on an icon below to copy its name and use it like this.
|
Click or tap on an icon below to copy its name and use it like this.
|
||||||
|
|
||||||
|
@ -56,15 +56,410 @@ Icons are sized relative to the current font size. To change their size, set the
|
||||||
|
|
||||||
### Custom Icons
|
### Custom Icons
|
||||||
|
|
||||||
Custom icons can be loaded individually with the `src` attribute. Only SVGs on a local or CORS-enabled endpoint are supported. If you're using more than one custom icon, it might make sense to register a [custom icon library](/components/icon-library.md).
|
Custom icons can be loaded individually with the `src` attribute. Only SVGs on a local or CORS-enabled endpoint are supported. If you're using more than one custom icon, it might make sense to register a [custom icon library](#icon-libraries).
|
||||||
|
|
||||||
```html preview
|
```html preview
|
||||||
<sl-icon src="/assets/images/shoe.svg" style="font-size: 8rem;"></sl-icon>
|
<sl-icon src="/assets/images/shoe.svg" style="font-size: 8rem;"></sl-icon>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Icon Libraries
|
||||||
|
|
||||||
|
Shoelace lets you register additional icons to use with the `<sl-icon>` component through icon libraries. The icon files can exist locally or on a CORS-enabled endpoint (e.g. a CDN). There is no limit to how many icon libraries you can register and there is no cost associated with registering them, as individual icons are only requested when they're used.
|
||||||
|
|
||||||
|
To register an icon library, use the `registerIconLibrary()` function that's exported from `utilities/icon-library.js`. At a minimum, you must provide a name and a resolver function. The resolver function translates an icon name to a URL where the corresponding SVG file exists. Refer to the examples below to better understand how it works.
|
||||||
|
|
||||||
|
If necessary, a mutator function can be used to mutate the SVG element before rendering. This is necessary for some libraries due to the many possible ways SVGs are crafted. For example, icons should ideally inherit the current text color via `currentColor`, so you may need to apply `fill="currentColor` or `stroke="currentColor"` to the SVG element using this function.
|
||||||
|
|
||||||
|
Here's an example that registers an icon library located in the `/assets/icons` directory.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/shoelace/dist/utilities/icon-library.js';
|
||||||
|
|
||||||
|
registerIconLibrary('my-icons', {
|
||||||
|
resolver: name => `/assets/icons/${name}.svg`,
|
||||||
|
mutator: svg => svg.setAttribute('fill', 'currentColor')
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
To display an icon, set the `library` and `name` attributes of an `<sl-icon>` element.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- This will show the icon located at /assets/icons/smile.svg -->
|
||||||
|
<sl-icon library="my-icons" name="smile"></sl-icon>
|
||||||
|
```
|
||||||
|
|
||||||
|
If an icon is used before registration occurs, it will be empty initially but shown when registered.
|
||||||
|
|
||||||
|
The following examples demonstrate how to register a number of popular, open source icon libraries via CDN. Feel free to adapt the code as you see fit to use your own origin or naming conventions.
|
||||||
|
|
||||||
|
### Boxicons
|
||||||
|
|
||||||
|
This will register the [Boxicons](https://boxicons.com/) library using the jsDelivr CDN. This library has three variations: regular (`bx-*`), solid (`bxs-*`), and logos (`bxl-*`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
||||||
|
|
||||||
|
Icons in this library are licensed under the [Creative Commons 4.0 License](https://github.com/atisawd/boxicons#license).
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/dist/shoelace.js';
|
||||||
|
|
||||||
|
registerIconLibrary('boxicons', {
|
||||||
|
resolver: name => {
|
||||||
|
let folder = 'regular';
|
||||||
|
if (name.substring(0, 4) === 'bxs-') folder = 'solid';
|
||||||
|
if (name.substring(0, 4) === 'bxl-') folder = 'logos';
|
||||||
|
return `https://cdn.jsdelivr.net/npm/boxicons@2.0.5/svg/${folder}/${name}.svg`;
|
||||||
|
},
|
||||||
|
mutator:svg => svg.setAttribute('fill', 'currentColor')
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
<sl-icon library="boxicons" name="bx-bot"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bx-cookie"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bx-joystick"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bx-save"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bx-server"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bx-wine"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="boxicons" name="bxs-bot"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxs-cookie"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxs-joystick"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxs-save"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxs-server"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxs-wine"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="boxicons" name="bxl-apple"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxl-chrome"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxl-edge"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxl-firefox"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxl-opera"></sl-icon>
|
||||||
|
<sl-icon library="boxicons" name="bxl-microsoft"></sl-icon>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feather Icons
|
||||||
|
|
||||||
|
This will register the [Feather Icons](https://feathericons.com/) library using the jsDelivr CDN.
|
||||||
|
|
||||||
|
Icons in this library are licensed under the [MIT License](https://github.com/feathericons/feather/blob/master/LICENSE).
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
<sl-icon library="feather" name="feather"></sl-icon>
|
||||||
|
<sl-icon library="feather" name="pie-chart"></sl-icon>
|
||||||
|
<sl-icon library="feather" name="settings"></sl-icon>
|
||||||
|
<sl-icon library="feather" name="map-pin"></sl-icon>
|
||||||
|
<sl-icon library="feather" name="printer"></sl-icon>
|
||||||
|
<sl-icon library="feather" name="shopping-cart"></sl-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/dist/shoelace.js';
|
||||||
|
|
||||||
|
registerIconLibrary('feather', {
|
||||||
|
resolver: name => `https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/icons/${name}.svg`
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Font Awesome
|
||||||
|
|
||||||
|
This will register the [Font Awesome Free](https://fontawesome.com/) library using the jsDelivr CDN. This library has three variations: regular (`far-*`), solid (`fas-*`), and brands (`fab-*`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
||||||
|
|
||||||
|
Icons in this library are licensed under the [Font Awesome Free License](https://github.com/FortAwesome/Font-Awesome/blob/master/LICENSE.txt). Some of the icons that appear on the Font Awesome website require a license and are therefore not available in the CDN.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/dist/shoelace.js';
|
||||||
|
|
||||||
|
registerIconLibrary('fa', {
|
||||||
|
resolver: name => {
|
||||||
|
const filename = name.replace(/^fa[rbs]-/, '');
|
||||||
|
let folder = 'regular';
|
||||||
|
if (name.substring(0, 4) === 'fas-') folder = 'solid';
|
||||||
|
if (name.substring(0, 4) === 'fab-') folder = 'brands';
|
||||||
|
return `https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.15.1/svgs/${folder}/${filename}.svg`;
|
||||||
|
},
|
||||||
|
mutator: svg => svg.setAttribute('fill', 'currentColor')
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
<sl-icon library="fa" name="far-bell"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="far-comment"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="far-hand-point-right"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="far-hdd"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="far-heart"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="far-star"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="fa" name="fas-archive"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fas-book"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fas-chess-knight"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fas-dice"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fas-pizza-slice"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fas-scroll"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="fa" name="fab-apple"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fab-chrome"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fab-edge"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fab-firefox"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fab-opera"></sl-icon>
|
||||||
|
<sl-icon library="fa" name="fab-microsoft"></sl-icon>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heroicons
|
||||||
|
|
||||||
|
This will register the [Heroicons](https://heroicons.com/) library using the jsDelivr CDN.
|
||||||
|
|
||||||
|
Icons in this library are licensed under the [MIT License](https://github.com/tailwindlabs/heroicons/blob/master/LICENSE).
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/dist/shoelace.js';
|
||||||
|
|
||||||
|
registerIconLibrary('heroicons', {
|
||||||
|
resolver: name => `https://cdn.jsdelivr.net/npm/heroicons@0.4.2/outline/${name}.svg`
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
<sl-icon library="heroicons" name="chat"></sl-icon>
|
||||||
|
<sl-icon library="heroicons" name="cloud"></sl-icon>
|
||||||
|
<sl-icon library="heroicons" name="cog"></sl-icon>
|
||||||
|
<sl-icon library="heroicons" name="document-text"></sl-icon>
|
||||||
|
<sl-icon library="heroicons" name="gift"></sl-icon>
|
||||||
|
<sl-icon library="heroicons" name="volume-up"></sl-icon>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ionicons
|
||||||
|
|
||||||
|
This will register the [Ionicons](https://ionicons.com/) library using the jsDelivr CDN. This library has three variations: outline (default), filled (`*-filled`), and sharp (`*-sharp`). A mutator function is required to polyfill a handful of styles we're not including.
|
||||||
|
|
||||||
|
Icons in this library are licensed under the [MIT License](https://github.com/ionic-team/ionicons/blob/master/LICENSE).
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/dist/shoelace.js';
|
||||||
|
|
||||||
|
registerIconLibrary('ionicons', {
|
||||||
|
resolver: name => `https://cdn.jsdelivr.net/npm/ionicons@5.1.2/dist/ionicons/svg/${name}.svg`,
|
||||||
|
mutator: svg => {
|
||||||
|
svg.setAttribute('fill', 'currentColor');
|
||||||
|
svg.setAttribute('stroke', 'currentColor');
|
||||||
|
[...svg.querySelectorAll('.ionicon-fill-none')].map(el => el.setAttribute('fill', 'none'));
|
||||||
|
[...svg.querySelectorAll('.ionicon-stroke-width')].map(el => el.setAttribute('stroke-width', '32px'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
<sl-icon library="ionicons" name="alarm"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="american-football"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="bug"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="chatbubble"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="settings"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="warning"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="ionicons" name="alarm-outline"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="american-football-outline"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="bug-outline"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="chatbubble-outline"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="settings-outline"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="warning-outline"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="ionicons" name="alarm-sharp"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="american-football-sharp"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="bug-sharp"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="chatbubble-sharp"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="settings-sharp"></sl-icon>
|
||||||
|
<sl-icon library="ionicons" name="warning-sharp"></sl-icon>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jam Icons
|
||||||
|
|
||||||
|
This will register the [Jam Icons](https://jam-icons.com/) library using the jsDelivr CDN. This library has two variations: regular (default) and filled (`*-f`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
||||||
|
|
||||||
|
Icons in this library are licensed under the [MIT License](https://github.com/michaelampr/jam/blob/master/LICENSE).
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/dist/shoelace.js';
|
||||||
|
|
||||||
|
registerIconLibrary('jam', {
|
||||||
|
resolver: name => `https://cdn.jsdelivr.net/npm/jam-icons@2.0.0/svg/${name}.svg`,
|
||||||
|
mutator: svg => svg.setAttribute('fill', 'currentColor')
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
<sl-icon library="jam" name="calendar"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="camera"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="filter"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="leaf"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="picture"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="set-square"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="jam" name="calendar-f"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="camera-f"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="filter-f"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="leaf-f"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="picture-f"></sl-icon>
|
||||||
|
<sl-icon library="jam" name="set-square-f"></sl-icon>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Material Icons
|
||||||
|
|
||||||
|
This will register the [Material Icons](https://material.io/resources/icons/?style=baseline) library using the jsDelivr CDN. This library has three variations: outline (default), round (`*_round`), and sharp (`*_sharp`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
||||||
|
|
||||||
|
Icons in this library are licensed under the [Apache 2.0 License](https://github.com/google/material-design-icons/blob/master/LICENSE).
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/dist/shoelace.js';
|
||||||
|
|
||||||
|
registerIconLibrary('material', {
|
||||||
|
resolver: name => {
|
||||||
|
const match = name.match(/^(.*?)(_(round|sharp))?$/);
|
||||||
|
return `https://cdn.jsdelivr.net/npm/@material-icons/svg@1.0.5/svg/${match[1]}/${match[3] || 'outline'}.svg`;
|
||||||
|
},
|
||||||
|
mutator: svg => svg.setAttribute('fill', 'currentColor')
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
<sl-icon library="material" name="notifications"></sl-icon>
|
||||||
|
<sl-icon library="material" name="email"></sl-icon>
|
||||||
|
<sl-icon library="material" name="delete"></sl-icon>
|
||||||
|
<sl-icon library="material" name="volume_up"></sl-icon>
|
||||||
|
<sl-icon library="material" name="settings"></sl-icon>
|
||||||
|
<sl-icon library="material" name="shopping_basket"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="material" name="notifications_round"></sl-icon>
|
||||||
|
<sl-icon library="material" name="email_round"></sl-icon>
|
||||||
|
<sl-icon library="material" name="delete_round"></sl-icon>
|
||||||
|
<sl-icon library="material" name="volume_up_round"></sl-icon>
|
||||||
|
<sl-icon library="material" name="settings_round"></sl-icon>
|
||||||
|
<sl-icon library="material" name="shopping_basket_round"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="material" name="notifications_sharp"></sl-icon>
|
||||||
|
<sl-icon library="material" name="email_sharp"></sl-icon>
|
||||||
|
<sl-icon library="material" name="delete_sharp"></sl-icon>
|
||||||
|
<sl-icon library="material" name="volume_up_sharp"></sl-icon>
|
||||||
|
<sl-icon library="material" name="settings_sharp"></sl-icon>
|
||||||
|
<sl-icon library="material" name="shopping_basket_sharp"></sl-icon>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remix Icon
|
||||||
|
|
||||||
|
This will register the [Remix Icon](https://remixicon.com/) library using the jsDelivr CDN. This library has two variations: line (default) and fill (`*-fill`). It also groups icons by categories, so the name must include the category and icon separated by a slash. A mutator function is required to set the SVG's `fill` to `currentColor`.
|
||||||
|
|
||||||
|
Icons in this library are licensed under the [Apache 2.0 License](https://github.com/Remix-Design/RemixIcon/blob/master/License).
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/dist/shoelace.js';
|
||||||
|
|
||||||
|
registerIconLibrary('remixicon', {
|
||||||
|
resolver: name => {
|
||||||
|
const match = name.match(/^(.*?)\/(.*?)(-(fill))?$/);
|
||||||
|
match[1] = match[1].charAt(0).toUpperCase() + match[1].slice(1);
|
||||||
|
return `https://cdn.jsdelivr.net/npm/remixicon@2.5.0/icons/${match[1]}/${match[2]}${match[3] || '-line'}.svg`;
|
||||||
|
},
|
||||||
|
mutator: svg => svg.setAttribute('fill', 'currentColor')
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
<sl-icon library="remixicon" name="business/cloud"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="design/brush"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="business/pie-chart"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="development/bug"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="media/image"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="system/alert"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="remixicon" name="business/cloud-fill"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="design/brush-fill"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="business/pie-chart-fill"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="development/bug-fill"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="media/image-fill"></sl-icon>
|
||||||
|
<sl-icon library="remixicon" name="system/alert-fill"></sl-icon>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unicons
|
||||||
|
|
||||||
|
This will register the [Unicons](https://iconscout.com/unicons) library using the jsDelivr CDN. This library has two variations: line (default) and solid (`*-s`). A mutator function is required to set the SVG's `fill` to `currentColor`.
|
||||||
|
|
||||||
|
Icons in this library are licensed under the [Apache 2.0 License](https://github.com/Iconscout/unicons/blob/master/LICENSE). Some of the icons that appear on the Unicons website, particularly many of the solid variations, require a license and are therefore not available in the CDN.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/dist/shoelace.js';
|
||||||
|
|
||||||
|
registerIconLibrary('unicons', {
|
||||||
|
resolver: name => {
|
||||||
|
const match = name.match(/^(.*?)(-s)?$/);
|
||||||
|
return `https://cdn.jsdelivr.net/npm/@iconscout/unicons@3.0.3/svg/${match[2] === '-s' ? 'solid' : 'line'}/${match[1]}.svg`;
|
||||||
|
},
|
||||||
|
mutator: svg => svg.setAttribute('fill', 'currentColor')
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
<sl-icon library="unicons" name="clock"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="graph-bar"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="padlock"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="polygon"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="rocket"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="star"></sl-icon>
|
||||||
|
<br>
|
||||||
|
<sl-icon library="unicons" name="clock-s"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="graph-bar-s"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="padlock-s"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="polygon-s"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="rocket-s"></sl-icon>
|
||||||
|
<sl-icon library="unicons" name="star-s"></sl-icon>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customizing the Default Library
|
||||||
|
|
||||||
|
Shoelace comes bundled with over 1,300 icons courtesy of the [Bootstrap Icons](https://icons.getbootstrap.com/) project. These are the default icons that display when you use `<sl-icon>` without a `name` attribute. If you prefer to have these icons resolve elsewhere, you can register an icon library with the `default` name and a custom resolver.
|
||||||
|
|
||||||
|
This example will load the same set of icons from the jsDelivr CDN instead of your local assets folder.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/shoelace/dist/utilities/icon-library.js';
|
||||||
|
|
||||||
|
registerIconLibrary('default', {
|
||||||
|
resolver: name => `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.0.0/icons/${name}.svg`
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can replace the default icons with a completely different icon set. Just keep in mind that some of the default icons are used by components so you'll want to make sure those names resolve to an appropriate alternative.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="module">
|
||||||
|
import { registerIconLibrary } from '/shoelace/dist/utilities/icon-library.js';
|
||||||
|
|
||||||
|
registerIconLibrary('default', {
|
||||||
|
name => `/my/custom/icons/${name}.svg`
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
<!-- Supporting scripts and styles for the search utility -->
|
<!-- Supporting scripts and styles for the search utility -->
|
||||||
<script>
|
<script>
|
||||||
fetch('/dist/shoelace/icons/icons.json')
|
fetch('/dist/assets/icons/icons.json')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(icons => {
|
.then(icons => {
|
||||||
const container = document.querySelector('.icon-search');
|
const container = document.querySelector('.icon-search');
|
||||||
|
|
|
@ -53,13 +53,7 @@ Use the `disable` attribute to disable the rating.
|
||||||
### Custom Icons
|
### Custom Icons
|
||||||
|
|
||||||
```html preview
|
```html preview
|
||||||
<sl-rating class="rating-hearts" style="--symbol-color-active: #ff4136;"></sl-rating>
|
<sl-rating symbol="heart-fill" style="--symbol-color-active: #ff4136;"></sl-rating>
|
||||||
|
|
||||||
<script>
|
|
||||||
const rating = document.querySelector('.rating-hearts');
|
|
||||||
|
|
||||||
rating.getSymbol = () => '<sl-icon name="heart-fill"></sl-icon>';
|
|
||||||
</script>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Value-based Icons
|
### Value-based Icons
|
||||||
|
@ -70,9 +64,9 @@ Use the `disable` attribute to disable the rating.
|
||||||
<script>
|
<script>
|
||||||
const rating = document.querySelector('.rating-emojis');
|
const rating = document.querySelector('.rating-emojis');
|
||||||
|
|
||||||
rating.getSymbol = (value) => {
|
rating.symbol = (value) => {
|
||||||
const icons = ['emoji-angry', 'emoji-frown', 'emoji-expressionless', 'emoji-smile', 'emoji-laughing'];
|
const icons = ['emoji-angry', 'emoji-frown', 'emoji-expressionless', 'emoji-smile', 'emoji-laughing'];
|
||||||
return `<sl-icon name="${icons[value - 1]}"></sl-icon>`;
|
return icons[value - 1];
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
# Theme
|
|
||||||
|
|
||||||
[component-header:sl-theme]
|
|
||||||
|
|
||||||
Themes change the visual appearance of components.
|
|
||||||
|
|
||||||
This component will activate a theme and apply its styles to everything inside. All themes must adhere to [theming guidelines](/getting-started/themes) and expose a class that follows the `sl-theme-{name}` convention.
|
|
||||||
|
|
||||||
To activate a theme, include the necessary stylesheet(s) and wrap your content in an `<sl-theme>` element. The theme to use is specified by the `name` prop.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<link rel="stylesheet" href="your-theme.css">
|
|
||||||
|
|
||||||
<sl-theme name="dark">
|
|
||||||
<!-- Everything inside will use the dark theme -->
|
|
||||||
</sl-theme>
|
|
||||||
```
|
|
||||||
|
|
||||||
?> It's important to note that the default "light" theme isn't actually a theme — it's a set of design tokens and base styles that themes can use as a foundation to build upon. As such, it's not possible to opt in to the default theme using this component.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Dark Theme
|
|
||||||
|
|
||||||
To use the official dark theme, include its stylesheet per the instructions on the [themes page](/getting-started/themes) and activate it as shown in the example below. All design tokens and components will render accordingly.
|
|
||||||
|
|
||||||
```html preview
|
|
||||||
<div class="theme-overview">
|
|
||||||
<sl-theme name="dark">
|
|
||||||
|
|
||||||
<!-- Design tokens used inside <sl-theme> will reflect the theme's colors -->
|
|
||||||
<div style="background-color: var(--sl-color-gray-900); padding: var(--sl-spacing-xx-large);">
|
|
||||||
<!-- These are just some sample components to demonstrate -->
|
|
||||||
<sl-dropdown>
|
|
||||||
<sl-button slot="trigger" caret>Dropdown</sl-button>
|
|
||||||
<sl-menu>
|
|
||||||
<sl-menu-item>Item 1</sl-menu-item>
|
|
||||||
<sl-menu-item>Item 2</sl-menu-item>
|
|
||||||
<sl-menu-item>Item 3</sl-menu-item>
|
|
||||||
</sl-menu>
|
|
||||||
</sl-dropdown>
|
|
||||||
|
|
||||||
<sl-dialog label="Dialog">
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
|
||||||
<sl-button slot="footer" type="primary">Close</sl-button>
|
|
||||||
</sl-dialog>
|
|
||||||
<sl-button>Open Dialog</sl-button>
|
|
||||||
|
|
||||||
<sl-checkbox>Check me</sl-checkbox>
|
|
||||||
</div>
|
|
||||||
</sl-theme>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
const container = document.querySelector('.theme-overview');
|
|
||||||
const dialog = container.querySelector('sl-dialog');
|
|
||||||
const openButton = dialog.nextElementSibling;
|
|
||||||
const closeButton = dialog.querySelector('sl-button[slot="footer"]');
|
|
||||||
|
|
||||||
openButton.addEventListener('click', () => dialog.show());
|
|
||||||
closeButton.addEventListener('click', () => dialog.hide());
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
[component-metadata:sl-theme]
|
|
|
@ -86,7 +86,7 @@ Use the `placement` attribute to set the preferred placement of the tooltip.
|
||||||
width: 250px;
|
width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-placement-example-row::after {
|
.tooltip-placement-example-row:after {
|
||||||
content: '';
|
content: '';
|
||||||
display: table;
|
display: table;
|
||||||
clear: both;
|
clear: both;
|
||||||
|
|
|
@ -6,10 +6,32 @@ Components with the <sl-badge type="warning" pill>Experimental</sl-badge> badge
|
||||||
|
|
||||||
_During the beta period, these restrictions may be relaxed in the event of a mission-critical bug._ 🐛
|
_During the beta period, these restrictions may be relaxed in the event of a mission-critical bug._ 🐛
|
||||||
|
|
||||||
## Next
|
## 2.0.0-beta.28
|
||||||
|
|
||||||
|
**This release includes a major under the hood overhaul of the library and how it's distributed.** Until now, Shoelace was developed with Stencil. This release moves to a lightweight tool called [Shoemaker](https://github.com/shoelace-style/shoemaker), a homegrown utility that provides declarative templating and data binding while reducing the boilerplate required for said features. The base class is open source and less than [200 lines of code](https://github.com/shoelace-style/shoemaker/blob/master/src/shoemaker.ts).
|
||||||
|
|
||||||
|
This change in tooling addresses a number of longstanding bugs and limitations. It also gives us more control over the library and build process while streamlining development and maintenance. Instead of two different distributions, Shoelace now offers a single, standards-compliant collection of ES modules. This may affect how you install and use the library, so please refer to the [installation page](/getting-started/installation) for details.
|
||||||
|
|
||||||
|
!> Due to the large number of internal changes, I would consider this update to be less stable than previous ones. If you're using Shoelace in a production app, consider holding off until the next beta to allow for more exhaustive testing from the community. Please report any bugs you find on the [issue tracker](https://github.com/shoelace-style/shoelace/issues).
|
||||||
|
|
||||||
|
The component API remains the same except for the changes noted below. Thanks for your patience as I work diligently to make Shoelace more stable and future-proof. 🙌
|
||||||
|
|
||||||
|
- 🚨 BREAKING: removed the custom elements bundle (you can import ES modules directly)
|
||||||
|
- 🚨 BREAKING: removed `getAnimationNames()` and `getEasingNames()` methods from `sl-animation` (you can import them from `utilities/animation.js` instead)
|
||||||
|
- 🚨 BREAKING: removed the `sl-icon-library` component since it required imperative initialization (you can import the `registerIconLibrary()` function from `utilities/icon-library.js` instead)
|
||||||
|
- 🚨 BREAKING: removed the experimental `sl-theme` component due to limitations (you should set the `sl-theme-[name]` class on the `<body>` instead)
|
||||||
|
- 🚨 BREAKING: moved the base stylesheet from `dist/shoelace.css` to `dist/themes/base.css`
|
||||||
|
- 🚨 BREAKING: moved `icons` into `assets/icons` to make future assets easier to colocate
|
||||||
|
- 🚨 BREAKING: changed `getSymbol` prop in `sl-rating` to `symbol` (it now accepts a string or a function that returns an icon name)
|
||||||
|
- 🚨 BREAKING: renamed `setAssetPath()` to `setBasePath()` and added the ability to set the library's base path with a `data-shoelace` attribute (`setBasePath()` is exported from `utilities/base-path.js`)
|
||||||
|
- Fixed `min` and `max` types in `sl-input` to allow numbers and strings [#330](https://github.com/shoelace-style/shoelace/issues/330)
|
||||||
- Fixed a bug where `sl-checkbox`, `sl-radio`, and `sl-switch` controls would shrink with long labels [#325](https://github.com/shoelace-style/shoelace/issues/325)
|
- Fixed a bug where `sl-checkbox`, `sl-radio`, and `sl-switch` controls would shrink with long labels [#325](https://github.com/shoelace-style/shoelace/issues/325)
|
||||||
|
- Fixed a bug in `sl-select` where the dropdown menu wouldn't reposition when the box resized [#340](https://github.com/shoelace-style/shoelace/issues/340)
|
||||||
- Fixed a bug where ignoring clicks and clicking the overlay would prevent the escape key from closing the dialog/drawer [#344](https://github.com/shoelace-style/shoelace/pull/344)
|
- Fixed a bug where ignoring clicks and clicking the overlay would prevent the escape key from closing the dialog/drawer [#344](https://github.com/shoelace-style/shoelace/pull/344)
|
||||||
|
- Removed the lazy loading dist (importing `shoelace.js` will load and register all components now)
|
||||||
|
- Switched from Stencil to Shoemaker
|
||||||
|
- Switched to a custom build powered by [esbuild](https://esbuild.github.io/)
|
||||||
|
- Updated to Bootstrap Icons 1.4.0
|
||||||
|
|
||||||
## 2.0.0-beta.27
|
## 2.0.0-beta.27
|
||||||
|
|
||||||
|
@ -18,7 +40,6 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
|
||||||
- Added "Integrating with NextJS" tutorial to the docs, courtesy of [crutchcorn](https://github.com/crutchcorn)
|
- Added "Integrating with NextJS" tutorial to the docs, courtesy of [crutchcorn](https://github.com/crutchcorn)
|
||||||
- Added `content` slot to `sl-tooltip` [#322](https://github.com/shoelace-style/shoelace/pull/322)
|
- Added `content` slot to `sl-tooltip` [#322](https://github.com/shoelace-style/shoelace/pull/322)
|
||||||
- Fixed a bug in `sl-select` where removing a tag would toggle the dropdown
|
- Fixed a bug in `sl-select` where removing a tag would toggle the dropdown
|
||||||
- Fixed a bug in `sl-select` where the dropdown menu wouldn't reposition when the box resized [#340](https://github.com/shoelace-style/shoelace/issues/340)
|
|
||||||
- Fixed a bug in `sl-input` and `sl-textarea` where the input might not exist when the value watcher is called [#313](https://github.com/shoelace-style/shoelace/issues/313)
|
- Fixed a bug in `sl-input` and `sl-textarea` where the input might not exist when the value watcher is called [#313](https://github.com/shoelace-style/shoelace/issues/313)
|
||||||
- Fixed a bug in `sl-details` where hidden elements would receive focus when tabbing [#323](https://github.com/shoelace-style/shoelace/issues/323)
|
- Fixed a bug in `sl-details` where hidden elements would receive focus when tabbing [#323](https://github.com/shoelace-style/shoelace/issues/323)
|
||||||
- Fixed a bug in `sl-icon` where `sl-error` would only be emitted for network failures [#326](https://github.com/shoelace-style/shoelace/pull/326)
|
- Fixed a bug in `sl-icon` where `sl-error` would only be emitted for network failures [#326](https://github.com/shoelace-style/shoelace/pull/326)
|
||||||
|
@ -29,7 +50,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
|
||||||
|
|
||||||
## 2.0.0-beta.26
|
## 2.0.0-beta.26
|
||||||
|
|
||||||
- 🚨 BREAKING CHANGE: Fixed animations bloat
|
- 🚨 BREAKING: Fixed animations bloat
|
||||||
- Removed ~400 baked-in Animista animations because they were causing ~200KB of bloat (they can still be used with custom keyframes)
|
- Removed ~400 baked-in Animista animations because they were causing ~200KB of bloat (they can still be used with custom keyframes)
|
||||||
- Reworked animations into a separate module ([`@shoelace-style/animations`](https://github.com/shoelace-style/animations)) so it's more maintainable and animations are sync with the latest version of animate.css
|
- Reworked animations into a separate module ([`@shoelace-style/animations`](https://github.com/shoelace-style/animations)) so it's more maintainable and animations are sync with the latest version of animate.css
|
||||||
- Animation and easing names are now camelcase (e.g. `easeInOut` instead of `ease-in-out`)
|
- Animation and easing names are now camelcase (e.g. `easeInOut` instead of `ease-in-out`)
|
||||||
|
@ -50,17 +71,17 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
|
||||||
|
|
||||||
## 2.0.0-beta.25
|
## 2.0.0-beta.25
|
||||||
|
|
||||||
- 🚨 BREAKING CHANGE: Reworked color tokens
|
- 🚨 BREAKING: Reworked color tokens
|
||||||
- Theme colors are now inspired by Tailwind's professionally-designed color palette
|
- Theme colors are now inspired by Tailwind's professionally-designed color palette
|
||||||
- Color token variations now range from 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950
|
- Color token variations now range from 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950
|
||||||
- Color token variations were inverted, e.g. 50 is lightest and 950 is darkest
|
- Color token variations were inverted, e.g. 50 is lightest and 950 is darkest
|
||||||
- All component styles were adapted to use the new color tokens, but visual changes are subtle
|
- All component styles were adapted to use the new color tokens, but visual changes are subtle
|
||||||
- The dark theme was adapted use the new color tokens
|
- The dark theme was adapted use the new color tokens
|
||||||
- HSL is no longer used because it is not perceptually uniform (this may be revisited when all browsers support [LCH colors](https://lea.verou.me/2020/04/lch-colors-in-css-what-why-and-how/))
|
- HSL is no longer used because it is not perceptually uniform (this may be revisited when all browsers support [LCH colors](https://lea.verou.me/2020/04/lch-colors-in-css-what-why-and-how/))
|
||||||
- 🚨 BREAKING CHANGE: Refactored `sl-select` to improve accessibility [#216](https://github.com/shoelace-style/shoelace/issues/216)
|
- 🚨 BREAKING: Refactored `sl-select` to improve accessibility [#216](https://github.com/shoelace-style/shoelace/issues/216)
|
||||||
- Removed the internal `sl-input` because it was causing problems with a11y and virtual keyboards
|
- Removed the internal `sl-input` because it was causing problems with a11y and virtual keyboards
|
||||||
- Removed `input`, `prefix` and `suffix` parts
|
- Removed `input`, `prefix` and `suffix` parts
|
||||||
- 🚨 BREAKING CHANGE: Removed `copy-button` part from `sl-color-picker` since copying is now done by clicking the preview
|
- 🚨 BREAKING: Removed `copy-button` part from `sl-color-picker` since copying is now done by clicking the preview
|
||||||
- Added `getFormattedValue()` method to `sl-color-picker` so you can retrieve the current value in any format
|
- Added `getFormattedValue()` method to `sl-color-picker` so you can retrieve the current value in any format
|
||||||
- Added visual separators between solid buttons in `sl-button-group`
|
- Added visual separators between solid buttons in `sl-button-group`
|
||||||
- Added `help-text` prop to `sl-input`, `sl-textarea`, and `sl-select`
|
- Added `help-text` prop to `sl-input`, `sl-textarea`, and `sl-select`
|
||||||
|
@ -108,7 +129,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
|
||||||
|
|
||||||
## 2.0.0-beta.22
|
## 2.0.0-beta.22
|
||||||
|
|
||||||
- 🚨 BREAKING CHANGE: Refactored `sl-menu` and `sl-menu-item` to improve accessibility by using proper focus states [#217](https://github.com/shoelace-style/shoelace/issues/217)
|
- 🚨 BREAKING: Refactored `sl-menu` and `sl-menu-item` to improve accessibility by using proper focus states [#217](https://github.com/shoelace-style/shoelace/issues/217)
|
||||||
- Moved `tabindex` from `sl-menu` to `sl-menu-item`
|
- Moved `tabindex` from `sl-menu` to `sl-menu-item`
|
||||||
- Removed the `active` prop from `sl-menu-item` because synthetic focus states are bad for accessibility
|
- Removed the `active` prop from `sl-menu-item` because synthetic focus states are bad for accessibility
|
||||||
- Removed the `sl-activate` and `sl-deactivate` events from `sl-menu-item` (listen for `focus` and `blur` instead)
|
- Removed the `sl-activate` and `sl-deactivate` events from `sl-menu-item` (listen for `focus` and `blur` instead)
|
||||||
|
@ -145,7 +166,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
|
||||||
|
|
||||||
## 2.0.0-beta.20
|
## 2.0.0-beta.20
|
||||||
|
|
||||||
- 🚨 BREAKING CHANGE: Transformed all Shoelace events to lowercase ([details](#why-did-event-names-change))
|
- 🚨 BREAKING: Transformed all Shoelace events to lowercase ([details](#why-did-event-names-change))
|
||||||
- Added support for dropdowns and non-icon elements to `sl-input`
|
- Added support for dropdowns and non-icon elements to `sl-input`
|
||||||
- Added `spellcheck` prop to `sl-input`
|
- Added `spellcheck` prop to `sl-input`
|
||||||
- Added `sl-icon-library` to allow custom icon library registration
|
- Added `sl-icon-library` to allow custom icon library registration
|
||||||
|
|
|
@ -4,13 +4,11 @@ You can use Shoelace via CDN or by installing it locally.
|
||||||
|
|
||||||
## CDN Installation (Recommended)
|
## CDN Installation (Recommended)
|
||||||
|
|
||||||
The easiest way to install Shoelace is with the CDN. A lightweight loader will be added to your page that registers components asynchronously as you use them. It's like magic. ✨
|
The easiest way to install Shoelace is with the CDN. Just add the following tags to your page.
|
||||||
|
|
||||||
Just add the following tags to your page.
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace/shoelace.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/base.css">
|
||||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace/shoelace.esm.js"></script>
|
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/all.shoelace.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you can [start using Shoelace!](/getting-started/usage.md)
|
Now you can [start using Shoelace!](/getting-started/usage.md)
|
||||||
|
@ -23,146 +21,92 @@ If you don't want to use the CDN, you can install Shoelace locally with the foll
|
||||||
npm install @shoelace-style/shoelace
|
npm install @shoelace-style/shoelace
|
||||||
```
|
```
|
||||||
|
|
||||||
It's up to you to make the source files available to your app. One way to do this is to create a route in your app called `/assets/shoelace` that serves static files from `node_modules/@shoelace-style/shoelace`.
|
It's up to you to make the source files available to your app. One way to do this is to create a route in your app called `/scripts/shoelace` that serves static files from `node_modules/@shoelace-style/shoelace`.
|
||||||
|
|
||||||
Once you've done that, add the following tags to your page. Make sure to update `href` and `src` so they point to the route you created.
|
Once you've done that, add the following tags to your page. Make sure to update `href` and `src` so they point to the route you created.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<link rel="stylesheet" href="/assets/shoelace/shoelace.css">
|
<link rel="stylesheet" href="/scripts/shoelace/dist/themes/base.css">
|
||||||
<script type="module" src="/assets/shoelace/shoelace.esm.js"></script>
|
<script type="module" src="/scripts/shoelace/dist/all.shoelace.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Importing Custom Elements
|
## Setting the Base Path
|
||||||
|
|
||||||
A [custom elements bundle](https://stenciljs.com/docs/custom-elements) is available so you can import components and register them individually. This is a more flexible alternative to the lazy loading approach, but it requires the use of a bundler such as [webpack](https://webpack.js.org/) or [Rollup](https://rollupjs.org/guide/en/). You'll also need to manage static assets on your own.
|
Some components rely on assets (icons, images, etc.) and Shoelace needs to know where they're located. For convenience, Shoelace will try to auto-detect the correct location based on the script you've loaded it from. This assumes assets are colocated with `shoelace.js` and will "just work" for most users.
|
||||||
|
|
||||||
Instructions vary depending on the bundler you're using.
|
However, if you're [cherry picking](#cherry-picking) or [bundling](#bundling) Shoelace, you'll need to set the base path. You can do this one of two ways. The following examples assumes you're serving Shoelace's `dist` directory from `/scripts/shoelace`.
|
||||||
|
|
||||||
## Using webpack
|
```html
|
||||||
|
<!-- Option 1: the data-shoelace attribute -->
|
||||||
|
<script src="bundle.js" data-shoelace="/scripts/shoelace"></script>
|
||||||
|
|
||||||
To use the custom elements bundle with webpack, install Shoelace first.
|
<!-- Option 2: the setBasePath() method -->
|
||||||
|
<script src="bundle.js"></script>
|
||||||
|
<script type="module">
|
||||||
|
import { setBasePath } from '/scripts/shoelace/dist/utilities/base-path.js';
|
||||||
|
setBasePath('/scripts/shoelace');
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
?> The library also exports a `getBasePath()` method you can use to reference assets.
|
||||||
|
|
||||||
|
## Cherry Picking
|
||||||
|
|
||||||
|
The previous approach is the _easiest_ way to load Shoelace, but easy isn't always efficient. You'll incur the full size of the library even if you only use a handful of components. This is convenient for prototyping, but may result in longer load times in production. To improve this, you can cherry pick the components you need.
|
||||||
|
|
||||||
|
Cherry picking can be done from your local install or [directly from the CDN](https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/). This will limit the number of files the browser has to download and reduce the amount of bytes being transferred. The disadvantage is that you need to load and register each component manually, including its dependencies.
|
||||||
|
|
||||||
|
Here's an example that loads only the button component and its dependencies.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- The base stylesheet is always required -->
|
||||||
|
<link rel="stylesheet" href="/scripts/shoelace/dist/themes/base.css">
|
||||||
|
|
||||||
|
<script type="module" data-shoelace="/scripts/shoelace">
|
||||||
|
import SlButton from '/scripts/shoelace/dist/components/button/button.js';
|
||||||
|
import SlSpinner from '/scripts/shoelace/dist/components/spinner/spinner.js';
|
||||||
|
|
||||||
|
SlButton.register();
|
||||||
|
SlSpinner.register(); // spinner is a dependency of button
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
If a component has dependencies, they'll be listed in the "Dependencies" section of its documentation. These are always Shoelace components, not third-party libraries. For example, `<sl-button>` requires you to load `<sl-spinner>` because it's used internally for its loading state.
|
||||||
|
|
||||||
|
!> Never cherry pick from `all.shoelace.js` or `shoelace.js` as this will cause the browser to load the entire library. Instead, cherry pick from component modules as shown above.
|
||||||
|
|
||||||
|
!> You may see files named `chunk.[hash].js` in the `dist` directory. Never reference these files directly, as they change from version to version. Instead, import the corresponding component or utility file.
|
||||||
|
|
||||||
|
## Bundling
|
||||||
|
|
||||||
|
Shoelace is distributed as a collection of standard ES modules that [all modern browsers can understand](https://caniuse.com/es6-module). However, importing a lot of modules can result in a lot of HTTP requests and potentially longer load times. Using a CDN can alleviate this, but some users may wish to further optimize their imports with a bundler.
|
||||||
|
|
||||||
|
To use Shoelace with a bundler, first install Shoelace as well as your bundler of choice.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @shoelace-style/shoelace
|
npm install @shoelace-style/shoelace
|
||||||
```
|
```
|
||||||
|
|
||||||
Your `webpack.config.js` should look something like what's shown below. Note how assets such as icons are copied from `node_modules` to `dist/icons` via the `CopyPlugin` utility.
|
Now it's time to configure your bundler. Configurations vary for each tool, but here are some examples to help you get started.
|
||||||
|
|
||||||
|
- [Example webpack config](https://github.com/shoelace-style/webpack-example/blob/master/webpack.config.js)
|
||||||
|
- [Example Rollup config](https://github.com/shoelace-style/rollup-example/blob/master/rollup.config.js)
|
||||||
|
|
||||||
|
Once your bundler is configured, you'll be able to import Shoelace components and utilities.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const path = require('path');
|
import '@shoelace-style/shoelace/dist/shoelace.css';
|
||||||
const CopyPlugin = require('copy-webpack-plugin');
|
import { setBasePath, SlButton, SlIcon, SlInput, SlRating } from '@shoelace-style/shoelace';
|
||||||
|
|
||||||
module.exports = {
|
// Set the pase path to the folder you copied Shoelace's assets to
|
||||||
entry: './src/index.js',
|
setBasePath('/dist/shoelace');
|
||||||
output: {
|
|
||||||
filename: 'main.js',
|
SlButton.register();
|
||||||
path: path.resolve(__dirname, 'dist')
|
SlIcon.register();
|
||||||
},
|
SlInput.register();
|
||||||
module: {
|
SlRating.register();
|
||||||
rules: [
|
|
||||||
{
|
// <sl-button>, <sl-icon>, <sl-input>, and <sl-rating> are ready to use!
|
||||||
test: /\.css$/i,
|
|
||||||
use: ['style-loader', 'css-loader']
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new CopyPlugin({
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
from: path.resolve(__dirname, 'node_modules/@shoelace-style/shoelace/dist/shoelace/icons'),
|
|
||||||
to: path.resolve(__dirname, 'dist/icons')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
]
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Next, import the components you want to use and set the assets directory.
|
Note that you need to register each component manually to add them to the custom element registry. Components aren't automatically registered to prevent bundlers from treeshaking them.
|
||||||
|
|
||||||
```js
|
|
||||||
import '@shoelace-style/shoelace/dist/shoelace/shoelace.css';
|
|
||||||
import { setAssetPath, SlButton, SlDropdown } from '@shoelace-style/shoelace';
|
|
||||||
|
|
||||||
setAssetPath(document.currentScript.src);
|
|
||||||
customElements.define('sl-button', SlButton);
|
|
||||||
customElements.define('sl-dropdown', SlDropdown);
|
|
||||||
```
|
|
||||||
|
|
||||||
For convenience, the bundle also exports a `defineCustomElements()` method. When this method is called, it will register all Shoelace components in the bundle.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import '@shoelace-style/shoelace/dist/shoelace/shoelace.css';
|
|
||||||
import { defineCustomElements, setAssetPath } from '@shoelace-style/shoelace';
|
|
||||||
|
|
||||||
setAssetPath(document.currentScript.src);
|
|
||||||
defineCustomElements();
|
|
||||||
```
|
|
||||||
|
|
||||||
While convenient for prototyping, importing all components will make your bundle larger. For best results, only import the components you're actually using.
|
|
||||||
|
|
||||||
?> An [example webpack project](https://github.com/shoelace-style/webpack-example) is also available on GitHub for your convenience.
|
|
||||||
|
|
||||||
## Using Rollup
|
|
||||||
|
|
||||||
To use the custom elements bundle with Rollup, install Shoelace first.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @shoelace-style/shoelace
|
|
||||||
```
|
|
||||||
|
|
||||||
Your `rollup.config.js` should look something like what's shown below. Note how assets such as icons are copied from `node_modules` to `dist/icons` via the `rollup-copy-plugin`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import path from 'path';
|
|
||||||
import commonjs from '@rollup/plugin-commonjs';
|
|
||||||
import copy from 'rollup-plugin-copy';
|
|
||||||
import postcss from 'rollup-plugin-postcss';
|
|
||||||
import resolve from '@rollup/plugin-node-resolve';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
input: 'src/index.js',
|
|
||||||
output: [{ dir: path.resolve('dist/'), format: 'es' }],
|
|
||||||
plugins: [
|
|
||||||
resolve(),
|
|
||||||
commonjs(),
|
|
||||||
postcss({
|
|
||||||
extensions: ['.css']
|
|
||||||
}),
|
|
||||||
copy({
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
src: path.resolve(__dirname, 'node_modules/@shoelace-style/shoelace/dist/shoelace/icons'),
|
|
||||||
dest: path.resolve(__dirname, 'dist')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
]
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Next, import the components you want to use and set the assets directory.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import '@shoelace-style/shoelace/dist/shoelace/shoelace.css';
|
|
||||||
import { setAssetPath, SlButton, SlDropdown } from '@shoelace-style/shoelace';
|
|
||||||
|
|
||||||
setAssetPath(document.currentScript.src);
|
|
||||||
customElements.define('sl-button', SlButton);
|
|
||||||
customElements.define('sl-dropdown', SlDropdown);
|
|
||||||
```
|
|
||||||
|
|
||||||
For convenience, the bundle also exports a `defineCustomElements()` method. When this method is called, it will register all Shoelace components in the bundle.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import '@shoelace-style/shoelace/dist/shoelace/shoelace.css';
|
|
||||||
import { defineCustomElements, setAssetPath } from '@shoelace-style/shoelace';
|
|
||||||
|
|
||||||
setAssetPath(document.currentScript.src);
|
|
||||||
defineCustomElements();
|
|
||||||
```
|
|
||||||
|
|
||||||
While convenient for prototyping, importing all components will make your bundle larger. For best results, only import the components you're actually using.
|
|
||||||
|
|
||||||
?> An [example Rollup project](https://github.com/shoelace-style/rollup-example) is also available on GitHub for your convenience.
|
|
||||||
|
|
|
@ -29,8 +29,8 @@
|
||||||
Add the following code to your page.
|
Add the following code to your page.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace/shoelace.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/base.css">
|
||||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace/shoelace.esm.js"></script>
|
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/all.shoelace.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you have access to all of Shoelace's components! Try adding a button:
|
Now you have access to all of Shoelace's components! Try adding a button:
|
||||||
|
@ -102,18 +102,18 @@ Designing, developing, and supporting this library requires a lot of time, effor
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="repo-button repo-button--github" href="https://github.com/shoelace-style/shoelace/stargazers" rel="noopener" target="_blank">
|
<a class="repo-button repo-button--github" href="https://github.com/shoelace-style/shoelace/stargazers" rel="noopener" target="_blank">
|
||||||
<sl-icon src="/assets/images/github.svg"></sl-icon> <span class="github-star-count">Star</span>
|
<sl-icon name="github"></sl-icon> <span class="github-star-count">Star</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="repo-button repo-button--twitter" href="https://twitter.com/shoelace_style" rel="noopener" target="_blank">
|
<a class="repo-button repo-button--twitter" href="https://twitter.com/shoelace_style" rel="noopener" target="_blank">
|
||||||
<sl-icon src="/assets/images/twitter.svg"></sl-icon> Follow
|
<sl-icon name="twitter"></sl-icon> Follow
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
Special thanks to the following projects and individuals that helped make Shoelace possible.
|
Special thanks to the following projects and individuals that helped make Shoelace possible.
|
||||||
|
|
||||||
- Components are compiled by [Stencil](https://stenciljs.com/)
|
- Components are built with [Shoemaker](https://github.com/shoelace-style/shoemaker)
|
||||||
- Documentation is powered by [Docsify](https://docsify.js.org/)
|
- Documentation is powered by [Docsify](https://docsify.js.org/)
|
||||||
- CDN services are provided by [jsDelivr](https://www.jsdelivr.com/)
|
- CDN services are provided by [jsDelivr](https://www.jsdelivr.com/)
|
||||||
- The default theme is based on color palettes from [Tailwind](https://tailwindcss.com/)
|
- The default theme is based on color palettes from [Tailwind](https://tailwindcss.com/)
|
||||||
|
|
|
@ -9,17 +9,15 @@ The default theme is included as part of `shoelace.css` and should always be loa
|
||||||
To install the dark theme, add the following to the `<head>` section of your app.
|
To install the dark theme, add the following to the `<head>` section of your app.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/themes/dark.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/themes/dark.css">
|
||||||
```
|
```
|
||||||
|
|
||||||
**Themes must be activated after importing!** You can do this with the [`<sl-theme>`](/components/theme.md) component. Only the elements inside of `<sl-theme>` will inherit the theme's styles.
|
**Themes must be activated after importing!** You can do this by adding the `sl-theme-[name]` class to the `<body>` element.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<sl-button>Light Mode</sl-button>
|
<body class="sl-theme-dark">
|
||||||
|
...
|
||||||
<sl-theme name="dark">
|
</body>
|
||||||
<sl-button>Dark Mode</sl-button>
|
|
||||||
</sl-theme>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Detecting the User's Color Scheme Preference
|
### Detecting the User's Color Scheme Preference
|
||||||
|
@ -75,7 +73,7 @@ To customize individual components, use the following syntax. Available "parts"
|
||||||
|
|
||||||
## Using a Custom Theme
|
## Using a Custom Theme
|
||||||
|
|
||||||
If a theme adheres to the guidelines above, you can use it by importing the stylesheet and activating it with the [`<sl-theme>`](/components/theme.md) component.
|
If a theme adheres to the guidelines above, you can use it by importing the stylesheet and activating it with the `sl-theme-[name]` class.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<head>
|
<head>
|
||||||
|
@ -83,10 +81,8 @@ If a theme adheres to the guidelines above, you can use it by importing the styl
|
||||||
<link rel="stylesheet" href="path/to/purple-power.css">
|
<link rel="stylesheet" href="path/to/purple-power.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body class="sl-theme-purple-power">
|
||||||
<sl-theme name="purple-power">
|
...
|
||||||
...
|
|
||||||
</sl-theme>
|
|
||||||
</body>
|
</body>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -41,9 +41,9 @@
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/touch-icon.png" />
|
||||||
|
|
||||||
<!-- Import Shoelace -->
|
<!-- Import Shoelace -->
|
||||||
<link rel="stylesheet" href="/dist/shoelace/shoelace.css" />
|
<link rel="stylesheet" href="/dist/themes/base.css" />
|
||||||
<link rel="stylesheet" href="/themes/dark.css" />
|
<link rel="stylesheet" href="/dist/themes/dark.css" />
|
||||||
<script type="module" src="/dist/shoelace/shoelace.esm.js"></script>
|
<script type="module" src="/dist/all.shoelace.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
@ -74,7 +74,7 @@
|
||||||
crossChapter: true,
|
crossChapter: true,
|
||||||
crossChapterText: false
|
crossChapterText: false
|
||||||
},
|
},
|
||||||
routerMode: window.ShoelaceDevServer ? 'hash' : 'history',
|
routerMode: location.port ? 'hash' : 'history',
|
||||||
search: {
|
search: {
|
||||||
maxAge: 86400000, // Expiration time, the default one day
|
maxAge: 86400000, // Expiration time, the default one day
|
||||||
paths: 'auto',
|
paths: 'auto',
|
||||||
|
@ -94,6 +94,7 @@
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.19.0/components/prism-bash.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.19.0/components/prism-bash.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.19.0/components/prism-jsx.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.19.0/components/prism-jsx.min.js"></script>
|
||||||
<script src="/assets/plugins/code-block/code-block.js"></script>
|
<script src="/assets/plugins/code-block/code-block.js"></script>
|
||||||
|
<script src="/assets/plugins/scroll-position/scroll-position.js"></script>
|
||||||
<script src="/assets/plugins/metadata/metadata.js"></script>
|
<script src="/assets/plugins/metadata/metadata.js"></script>
|
||||||
<script src="/assets/plugins/sidebar/sidebar.js"></script>
|
<script src="/assets/plugins/sidebar/sidebar.js"></script>
|
||||||
<script src="/assets/plugins/theme/theme.js"></script>
|
<script src="/assets/plugins/theme/theme.js"></script>
|
||||||
|
|
|
@ -22,7 +22,7 @@ yarn add @shoelace-style/shoelace @shoelace-style/react-wrapper copy-webpack-plu
|
||||||
The next step is to import Shoelace's default theme (stylesheet) in your `_app.js` file:
|
The next step is to import Shoelace's default theme (stylesheet) in your `_app.js` file:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@import '~@shoelace-style/shoelace/dist/shoelace/shoelace';
|
@import '~@shoelace-style/shoelace/dist/themes/base';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Defining Custom Elements
|
### Defining Custom Elements
|
||||||
|
@ -40,10 +40,13 @@ function CustomEls({ URL }) {
|
||||||
if (customEls.current) {
|
if (customEls.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAssetPath(`${URL}/static/static`);
|
setBasePath(`${URL}/static/static`);
|
||||||
// If you're wanting to selectively import components, replace this line with your own definitions
|
|
||||||
defineCustomElements();
|
// Define the components you intend to use
|
||||||
// customElements.define("sl-button", SlButton);
|
customElements.define("sl-alert", SlAlert);
|
||||||
|
customElements.define("sl-button", SlButton);
|
||||||
|
// ...
|
||||||
|
|
||||||
customEls.current = true;
|
customEls.current = true;
|
||||||
}, [URL, customEls]);
|
}, [URL, customEls]);
|
||||||
|
|
||||||
|
@ -74,7 +77,7 @@ function MyApp({ Component, pageProps, URL }) {
|
||||||
|
|
||||||
### Environmental Variable
|
### Environmental Variable
|
||||||
|
|
||||||
However, to make the `setAssetsPath` work as-expected, we need to know where the file is hosted. To do this, we need to set [environmental variables](https://nextjs.org/docs/basic-features/environment-variables). Create a `.local.env` file and put the following inside:
|
However, to make `setBasePath()` work as-expected, we need to know where the file is hosted. To do this, we need to set [environmental variables](https://nextjs.org/docs/basic-features/environment-variables). Create a `.local.env` file and put the following inside:
|
||||||
|
|
||||||
```
|
```
|
||||||
BASE_URL="localhost:3000"
|
BASE_URL="localhost:3000"
|
||||||
|
@ -96,7 +99,7 @@ MyApp.getInitialProps = async (context) => {
|
||||||
|
|
||||||
### webpack Config
|
### webpack Config
|
||||||
|
|
||||||
Next we need to add Shoelace's icons to the final build output. To do this, modify `next.config.js` to look like this.
|
Next we need to add Shoelace's assets to the final build output. To do this, modify `next.config.js` to look like this.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
@ -110,9 +113,9 @@ module.exports = {
|
||||||
{
|
{
|
||||||
from: path.resolve(
|
from: path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
"node_modules/@shoelace-style/shoelace/dist/shoelace/icons"
|
"node_modules/@shoelace-style/shoelace/dist/assets"
|
||||||
),
|
),
|
||||||
to: path.resolve(__dirname, "static/icons"),
|
to: path.resolve(__dirname, "static/assets"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
@ -122,7 +125,7 @@ module.exports = {
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
?> This will copy the files from `node_modules` into your `static` folder on every development serve or build. You may want to avoid commiting these into your repo. To do so, simply add `static/icons` into your `.gitignore` folder
|
?> This will copy the files from `node_modules` into your `static` folder on every development serve or build. You may want to avoid commiting these into your repo. To do so, simply add `static/assets` into your `.gitignore` folder
|
||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ yarn add @shoelace-style/shoelace copy-webpack-plugin
|
||||||
The next step is to import Shoelace's default theme (stylesheet) in `app/javascript/stylesheets/application.scss`.
|
The next step is to import Shoelace's default theme (stylesheet) in `app/javascript/stylesheets/application.scss`.
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@import '~@shoelace-style/shoelace/dist/shoelace/shoelace';
|
@import '~@shoelace-style/shoelace/dist/themes/base';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Importing Required Scripts
|
### Importing Required Scripts
|
||||||
|
@ -32,25 +32,20 @@ After importing the theme, you'll need to import the JavaScript files for Shoela
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import '../stylesheets/application.scss'
|
import '../stylesheets/application.scss'
|
||||||
import { defineCustomElements, setAssetPath } from '@shoelace-style/shoelace'
|
import { setBasePath, SlAlert, SlAnimation, SlButton, ... } from '@shoelace-style/shoelace'
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
|
|
||||||
const rootUrl = document.currentScript.src.replace(/\/packs.*$/, '')
|
const rootUrl = document.currentScript.src.replace(/\/packs.*$/, '')
|
||||||
|
|
||||||
// Path to the assets folder (should be independent on the current script source path
|
// Path to the assets folder (should be independent from the current script source path
|
||||||
// to work correctly in different environments)
|
// to work correctly in different environments)
|
||||||
setAssetPath(rootUrl + '/packs/js/')
|
setBasePath(rootUrl + '/packs/js/')
|
||||||
|
|
||||||
// This enables all web components for the current page
|
|
||||||
defineCustomElements()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
?> This will import all Shoelace components for convenience. To selectively import components, refer to the [Using webpack](/getting-started/installation?id=using-webpack) section of the docs.
|
|
||||||
|
|
||||||
### webpack Config
|
### webpack Config
|
||||||
|
|
||||||
Next we need to add Shoelace's icons to the final build output. To do this, modify `config/webpack/environment.js` to look like this.
|
Next we need to add Shoelace's assets to the final build output. To do this, modify `config/webpack/environment.js` to look like this.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const { environment } = require('@rails/webpacker')
|
const { environment } = require('@rails/webpacker')
|
||||||
|
@ -59,7 +54,7 @@ const { environment } = require('@rails/webpacker')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const CopyPlugin = require('copy-webpack-plugin')
|
const CopyPlugin = require('copy-webpack-plugin')
|
||||||
|
|
||||||
// Add shoelace icons to webpack's build process
|
// Add shoelace assets to webpack's build process
|
||||||
environment.plugins.append(
|
environment.plugins.append(
|
||||||
'CopyPlugin',
|
'CopyPlugin',
|
||||||
new CopyPlugin({
|
new CopyPlugin({
|
||||||
|
@ -67,9 +62,9 @@ environment.plugins.append(
|
||||||
{
|
{
|
||||||
from: path.resolve(
|
from: path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../../node_modules/@shoelace-style/shoelace/dist/shoelace/icons'
|
'../../node_modules/@shoelace-style/shoelace/dist/assets'
|
||||||
),
|
),
|
||||||
to: path.resolve(__dirname, '../../public/packs/js/icons')
|
to: path.resolve(__dirname, '../../public/packs/js/assets')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
15
make-dist.js
15
make-dist.js
|
@ -1,15 +0,0 @@
|
||||||
const chalk = require('chalk');
|
|
||||||
const copy = require('recursive-copy');
|
|
||||||
const del = require('del');
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
// Create the docs distribution
|
|
||||||
console.log(chalk.cyan('Creating docs distribution 📚\n'));
|
|
||||||
await del('./docs/dist');
|
|
||||||
await copy('./dist', './docs/dist');
|
|
||||||
await copy('./themes', './docs/themes', { overwrite: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
})();
|
|
Plik diff jest za duży
Load Diff
116
package.json
116
package.json
|
@ -2,25 +2,24 @@
|
||||||
"name": "@shoelace-style/shoelace",
|
"name": "@shoelace-style/shoelace",
|
||||||
"description": "A forward-thinking library of web components.",
|
"description": "A forward-thinking library of web components.",
|
||||||
"version": "2.0.0-beta.27",
|
"version": "2.0.0-beta.27",
|
||||||
"homepage": "https://shoelace.style/",
|
"homepage": "https://github.com/shoelace-style/shoelace",
|
||||||
"author": "Cory LaViska",
|
"author": "Cory LaViska",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "dist/index.cjs.js",
|
"main": "dist/shoelace.js",
|
||||||
"module": "dist/custom-elements-bundle/index.js",
|
"module": "dist/shoelace.js",
|
||||||
"es2015": "dist/esm/index.js",
|
"type": "module",
|
||||||
"es2017": "dist/esm/index.js",
|
"types": "dist/shoelace.d.ts",
|
||||||
"jsnext:main": "dist/esm/index.js",
|
|
||||||
"collection": "dist/collection/collection-manifest.json",
|
|
||||||
"collection:main": "dist/collection/index.js",
|
|
||||||
"types": "dist/custom-elements-bundle/index.d.ts",
|
|
||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist"
|
||||||
"loader/",
|
],
|
||||||
"themes/"
|
"keywords": [
|
||||||
|
"web components",
|
||||||
|
"custom elements",
|
||||||
|
"components"
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git://github.com/shoelace-style/shoelace.git"
|
"url": "git+https://github.com/shoelace-style/shoelace.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/shoelace-style/shoelace/issues"
|
"url": "https://github.com/shoelace-style/shoelace/issues"
|
||||||
|
@ -30,62 +29,51 @@
|
||||||
"url": "https://github.com/sponsors/claviska"
|
"url": "https://github.com/sponsors/claviska"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "stencil build --docs",
|
"start": "node scripts/build.cjs --serve",
|
||||||
"dev": "npm run make-icons && stencil build --dev --docs --watch --serve --port 4001 --no-open",
|
"build": "node scripts/build.cjs",
|
||||||
"lint": "eslint src/**/*{.ts,.tsx}",
|
"prepublish": "npm run build",
|
||||||
"make-dist": "node make-dist.js",
|
"prettier": "prettier --write --loglevel warn ."
|
||||||
"make-icons": "node make-icons.js",
|
|
||||||
"prettier": "prettier --write --loglevel warn .",
|
|
||||||
"postbuild": "npm run make-dist",
|
|
||||||
"prebuild": "npm run prettier && npm run lint && npm run make-icons",
|
|
||||||
"serve": "node dev-server.js",
|
|
||||||
"start": "concurrently --kill-others \"npm run dev\" \"npm run serve\"",
|
|
||||||
"test.watch": "stencil test --spec --e2e --watchAll",
|
|
||||||
"test": "stencil test --spec --e2e",
|
|
||||||
"version": "npm run build"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@stencil/eslint-plugin": "^0.3.1",
|
|
||||||
"@stencil/sass": "^1.3.2",
|
|
||||||
"@types/color": "^3.0.1",
|
|
||||||
"@types/jest": "25.2.3",
|
|
||||||
"@types/puppeteer": "2.0.1",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^4.0.1",
|
|
||||||
"@typescript-eslint/parser": "^4.0.1",
|
|
||||||
"bluebird": "^3.7.2",
|
|
||||||
"bootstrap-icons": "^1.3.0",
|
|
||||||
"browser-sync": "^2.26.13",
|
|
||||||
"chalk": "^4.0.0",
|
|
||||||
"concurrently": "^5.1.0",
|
|
||||||
"del": "^5.1.0",
|
|
||||||
"download": "^8.0.0",
|
|
||||||
"eslint": "^7.7.0",
|
|
||||||
"eslint-plugin-react": "^7.20.6",
|
|
||||||
"express": "^4.17.1",
|
|
||||||
"front-matter": "^4.0.2",
|
|
||||||
"glob": "^7.1.6",
|
|
||||||
"http-proxy": "^1.18.1",
|
|
||||||
"http-proxy-middleware": "^1.0.4",
|
|
||||||
"husky": "^4.2.5",
|
|
||||||
"jest": "26.0.1",
|
|
||||||
"jest-cli": "26.0.1",
|
|
||||||
"prettier": "^2.0.5",
|
|
||||||
"puppeteer": "2.1.1",
|
|
||||||
"recursive-copy": "^2.0.10",
|
|
||||||
"through2": "^3.0.1",
|
|
||||||
"typescript": "^4.0.2",
|
|
||||||
"workbox-build": "4.3.1"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.5.3",
|
"@popperjs/core": "^2.7.0",
|
||||||
"@shoelace-style/animations": "^1.0.0",
|
"@shoelace-style/animations": "^1.1.0",
|
||||||
"@stencil/core": "^2.4.0",
|
"@shoelace-style/shoemaker": "^1.0.0-beta.6",
|
||||||
"@types/resize-observer-browser": "^0.1.4",
|
"color": "^3.1.3"
|
||||||
"color": "^3.1.2"
|
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/color": "^3.0.1",
|
||||||
|
"@types/resize-observer-browser": "^0.1.5",
|
||||||
|
"bluebird": "^3.7.2",
|
||||||
|
"bootstrap-icons": "^1.4.0",
|
||||||
|
"browser-sync": "^2.26.14",
|
||||||
|
"chalk": "^4.1.0",
|
||||||
|
"command-line-args": "^5.1.1",
|
||||||
|
"comment-parser": "^1.1.2",
|
||||||
|
"concurrently": "^5.3.0",
|
||||||
|
"del": "^6.0.0",
|
||||||
|
"download": "^8.0.0",
|
||||||
|
"esbuild": "^0.8.50",
|
||||||
|
"esbuild-plugin-inline-import": "^1.0.0",
|
||||||
|
"esbuild-plugin-sass": "^0.3.3",
|
||||||
|
"front-matter": "^4.0.2",
|
||||||
|
"get-port": "^5.1.1",
|
||||||
|
"glob": "^7.1.6",
|
||||||
|
"husky": "^4.3.8",
|
||||||
|
"prettier": "^2.2.1",
|
||||||
|
"recursive-copy": "^2.0.11",
|
||||||
|
"sass": "^1.32.7",
|
||||||
|
"tiny-glob": "^0.2.8",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"typedoc": "^0.20.28",
|
||||||
|
"typescript": "4.1.5",
|
||||||
|
"wait-on": "^5.2.1"
|
||||||
|
},
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css"
|
||||||
|
],
|
||||||
"husky": {
|
"husky": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"pre-commit": "npm run prettier && npm run lint"
|
"pre-commit": "npm run prettier"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,140 @@
|
||||||
|
//
|
||||||
|
// Builds the project. To spin up a dev server, pass the --serve flag.
|
||||||
|
//
|
||||||
|
const bs = require('browser-sync').create();
|
||||||
|
const chalk = require('chalk');
|
||||||
|
const commandLineArgs = require('command-line-args');
|
||||||
|
const copy = require('recursive-copy');
|
||||||
|
const del = require('del');
|
||||||
|
const esbuild = require('esbuild');
|
||||||
|
const execSync = require('child_process').execSync;
|
||||||
|
const getPort = require('get-port');
|
||||||
|
const glob = require('tiny-glob');
|
||||||
|
const inlineImportPlugin = require('esbuild-plugin-inline-import');
|
||||||
|
const path = require('path');
|
||||||
|
const sass = require('sass');
|
||||||
|
const sassPlugin = require('esbuild-plugin-sass');
|
||||||
|
const { build } = require('esbuild');
|
||||||
|
|
||||||
|
const options = commandLineArgs({
|
||||||
|
name: 'serve',
|
||||||
|
type: Boolean
|
||||||
|
});
|
||||||
|
|
||||||
|
execSync(`rm -rf ./dist`, { stdio: 'inherit' });
|
||||||
|
execSync('node scripts/make-metadata.cjs', { stdio: 'inherit' });
|
||||||
|
execSync('node scripts/make-icons.cjs', { stdio: 'inherit' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const entryPoints = [
|
||||||
|
// The main dist
|
||||||
|
'./src/shoelace.ts',
|
||||||
|
// The whole shebang
|
||||||
|
'./src/all.shoelace.ts',
|
||||||
|
// Components
|
||||||
|
...(await glob('./src/components/**/*.ts')),
|
||||||
|
// Public utilities
|
||||||
|
...(await glob('./src/utilities/**/*.ts')),
|
||||||
|
// Theme stylesheets
|
||||||
|
...(await glob('./src/themes/**/*.ts'))
|
||||||
|
];
|
||||||
|
|
||||||
|
const buildResult = await esbuild
|
||||||
|
.build({
|
||||||
|
format: 'esm',
|
||||||
|
target: 'es2017',
|
||||||
|
entryPoints,
|
||||||
|
outdir: './dist',
|
||||||
|
incremental: options.serve,
|
||||||
|
define: {
|
||||||
|
// Popper.js expects this to be set
|
||||||
|
'process.env.NODE_ENV': '"production"'
|
||||||
|
},
|
||||||
|
bundle: true,
|
||||||
|
splitting: true,
|
||||||
|
plugins: [
|
||||||
|
// Run inline style imports through Sass
|
||||||
|
inlineImportPlugin({
|
||||||
|
filter: /^sass:/,
|
||||||
|
transform: async (contents, args) => {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
sass.render(
|
||||||
|
{
|
||||||
|
data: contents,
|
||||||
|
includePaths: [path.dirname(args.path)]
|
||||||
|
},
|
||||||
|
(err, result) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(result.css.toString());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// Run all other stylesheets through Sass
|
||||||
|
sassPlugin()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(chalk.red(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the docs distribution by copying dist into docs/dist. This is what powers the website. It can't exist in dev
|
||||||
|
// because it will conflict with browser sync's routing to the actual dist dir.
|
||||||
|
await del('./docs/dist');
|
||||||
|
if (!options.serve) {
|
||||||
|
await copy('./dist', './docs/dist');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.green('The build has been generated! 📦'));
|
||||||
|
|
||||||
|
if (options.serve) {
|
||||||
|
const port = await getPort({
|
||||||
|
port: getPort.makeRange(4000, 4999)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(chalk.cyan(`\nLaunching the Shoelace dev server at http://localhost:${port}! 🥾\n`));
|
||||||
|
|
||||||
|
// Launch browser sync
|
||||||
|
bs.init({
|
||||||
|
startPath: '/',
|
||||||
|
port,
|
||||||
|
logLevel: 'silent',
|
||||||
|
logPrefix: '[shoelace]',
|
||||||
|
logFileChanges: true,
|
||||||
|
notify: false,
|
||||||
|
server: {
|
||||||
|
baseDir: 'docs',
|
||||||
|
routes: {
|
||||||
|
'/dist': './dist'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rebuild and reload when source files change
|
||||||
|
bs.watch(['src/**/*']).on('change', async filename => {
|
||||||
|
console.log(`Source file changed - ${filename}`);
|
||||||
|
|
||||||
|
// NOTE: we don't run TypeDoc on every change because it's quite heavy, so changes to the docs won't be included
|
||||||
|
// until the next time the build script runs.
|
||||||
|
buildResult
|
||||||
|
.rebuild()
|
||||||
|
.then(() => bs.reload())
|
||||||
|
.catch(err => console.error(chalk.red(err)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload without rebuilding when the docs change
|
||||||
|
bs.watch(['docs/**/*']).on('change', filename => {
|
||||||
|
console.log(`Docs file changed - ${filename}`);
|
||||||
|
bs.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on exit
|
||||||
|
process.on('SIGTERM', () => buildResult.rebuild.dispose());
|
||||||
|
}
|
||||||
|
})();
|
|
@ -1,25 +1,31 @@
|
||||||
|
//
|
||||||
|
// This script downloads and generates icons and icon metadata.
|
||||||
|
//
|
||||||
const Promise = require('bluebird');
|
const Promise = require('bluebird');
|
||||||
const promisify = require('util').promisify;
|
const promisify = require('util').promisify;
|
||||||
const chalk = require('chalk');
|
const chalk = require('chalk');
|
||||||
const copy = require('recursive-copy');
|
const copy = require('recursive-copy');
|
||||||
const del = require('del');
|
const del = require('del');
|
||||||
const download = require('download');
|
const download = require('download');
|
||||||
|
const mkdirp = require('mkdirp');
|
||||||
const fm = require('front-matter');
|
const fm = require('front-matter');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const glob = promisify(require('glob'));
|
const glob = promisify(require('glob'));
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
const baseDir = path.dirname(__dirname);
|
||||||
|
const iconDir = './dist/assets/icons';
|
||||||
let numIcons = 0;
|
let numIcons = 0;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const version = require('./node_modules/bootstrap-icons/package.json').version;
|
const version = require('bootstrap-icons/package').version;
|
||||||
const srcPath = `./.cache/icons/icons-${version}`;
|
const srcPath = `./.cache/icons/icons-${version}`;
|
||||||
const url = `https://github.com/twbs/icons/archive/v${version}.zip`;
|
const url = `https://github.com/twbs/icons/archive/v${version}.zip`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.stat(`${srcPath}/LICENSE.md`);
|
await fs.stat(`${srcPath}/LICENSE.md`);
|
||||||
console.log(chalk.cyan('Generating icons from cache ♻️'));
|
console.log(chalk.cyan('Generating icons from cache'));
|
||||||
} catch {
|
} catch {
|
||||||
// Download the source from GitHub (since not everything is published to NPM)
|
// Download the source from GitHub (since not everything is published to NPM)
|
||||||
console.log(chalk.cyan(`Downloading and extracting Bootstrap Icons ${version} 📦`));
|
console.log(chalk.cyan(`Downloading and extracting Bootstrap Icons ${version} 📦`));
|
||||||
|
@ -27,16 +33,17 @@ let numIcons = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy icons
|
// Copy icons
|
||||||
console.log(chalk.cyan(`Copying icons and license 🚛`));
|
console.log(chalk.cyan(`Copying icons and license`));
|
||||||
await del(['./src/components/icon/icons']);
|
await del([iconDir]);
|
||||||
|
await mkdirp(iconDir);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
copy(`${srcPath}/icons`, './src/components/icon/icons'),
|
copy(`${srcPath}/icons`, iconDir),
|
||||||
copy(`${srcPath}/LICENSE.md`, './src/components/icon/icons/LICENSE.md'),
|
copy(`${srcPath}/LICENSE.md`, path.join(iconDir, 'LICENSE.md')),
|
||||||
copy(`${srcPath}/bootstrap-icons.svg`, './docs/assets/icons/sprite.svg', { overwrite: true })
|
copy(`${srcPath}/bootstrap-icons.svg`, './docs/assets/icons/sprite.svg', { overwrite: true })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Generate metadata
|
// Generate metadata
|
||||||
console.log(chalk.cyan(`Generating icon metadata 🏷`));
|
console.log(chalk.cyan(`Generating icon metadata`));
|
||||||
const files = await glob(`${srcPath}/docs/content/icons/**/*.md`);
|
const files = await glob(`${srcPath}/docs/content/icons/**/*.md`);
|
||||||
|
|
||||||
const metadata = await Promise.map(files, async file => {
|
const metadata = await Promise.map(files, async file => {
|
||||||
|
@ -52,9 +59,9 @@ let numIcons = 0;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await fs.writeFile('./src/components/icon/icons/icons.json', JSON.stringify(metadata, null, 2), 'utf8');
|
await fs.writeFile(path.join(iconDir, 'icons.json'), JSON.stringify(metadata, null, 2), 'utf8');
|
||||||
|
|
||||||
console.log(chalk.green(`Successfully processed ${numIcons} icons! ✨\n`));
|
console.log(chalk.green(`Successfully processed ${numIcons} icons ✨\n`));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
|
@ -0,0 +1,168 @@
|
||||||
|
//
|
||||||
|
// This script runs TypeDoc and uses its output to generate components.json which is used for the docs.
|
||||||
|
//
|
||||||
|
const chalk = require('chalk');
|
||||||
|
const execSync = require('child_process').execSync;
|
||||||
|
const fs = require('fs');
|
||||||
|
const mkdirp = require('mkdirp');
|
||||||
|
const path = require('path');
|
||||||
|
const package = require('../package.json');
|
||||||
|
const { parse } = require('comment-parser/lib');
|
||||||
|
|
||||||
|
function getTagName(className) {
|
||||||
|
return className.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeInfo(item) {
|
||||||
|
let type = item.type.name || '';
|
||||||
|
|
||||||
|
if (item.type.type === 'union') {
|
||||||
|
const types = item.type.types.map(t => {
|
||||||
|
if (t.type === 'literal' || t.type === 'reference') {
|
||||||
|
type = `'${item.type.types.map(t => t.value).join(`' | '`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.type === 'intrinsic') {
|
||||||
|
type = item.type.types.map(t => t.name).join(' | ');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits a string of tag text into a { name, description } object
|
||||||
|
function splitText(text) {
|
||||||
|
const shouldSplit = text.indexOf(' - ') > -1;
|
||||||
|
let name = '';
|
||||||
|
let description = '';
|
||||||
|
|
||||||
|
if (shouldSplit) {
|
||||||
|
const split = text.split(' - ');
|
||||||
|
name = split[0].trim();
|
||||||
|
description = split.slice(1).join(' - ').replace(/^- /, '');
|
||||||
|
} else {
|
||||||
|
description = text.trim().replace(/^-\s/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, description };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run typedoc
|
||||||
|
console.log(chalk.cyan('Generating type data with TypeDoc'));
|
||||||
|
mkdirp.sync('./.cache');
|
||||||
|
execSync(
|
||||||
|
'typedoc --json .cache/typedoc.json --entryPoints src/shoelace.ts --exclude "**/*+(index|.spec|.e2e).ts" --excludeExternals --excludeProtected --excludeInternal'
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync('.cache/typedoc.json', 'utf8'));
|
||||||
|
const modules = data.children;
|
||||||
|
const components = modules.filter(module => module.kindString === 'Class');
|
||||||
|
const output = {
|
||||||
|
name: package.name,
|
||||||
|
description: package.description,
|
||||||
|
version: package.version,
|
||||||
|
author: package.author,
|
||||||
|
homepage: package.homepage,
|
||||||
|
license: package.license,
|
||||||
|
components: []
|
||||||
|
};
|
||||||
|
|
||||||
|
components.map(async component => {
|
||||||
|
const api = {
|
||||||
|
className: component.name,
|
||||||
|
tag: getTagName(component.name),
|
||||||
|
file: component.sources[0].fileName,
|
||||||
|
since: '',
|
||||||
|
status: '',
|
||||||
|
props: [],
|
||||||
|
methods: [],
|
||||||
|
events: [],
|
||||||
|
slots: [],
|
||||||
|
cssCustomProperties: [],
|
||||||
|
parts: [],
|
||||||
|
dependencies: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
if (component.comment) {
|
||||||
|
const tags = component.comment.tags;
|
||||||
|
const dependencies = tags.filter(item => item.tag === 'dependency');
|
||||||
|
const slots = tags.filter(item => item.tag === 'slot');
|
||||||
|
const parts = tags.filter(item => item.tag === 'part');
|
||||||
|
const events = tags.filter(item => item.tag === 'emit');
|
||||||
|
|
||||||
|
api.since = tags.find(item => item.tag === 'since').text.trim();
|
||||||
|
api.status = tags.find(item => item.tag === 'status').text.trim();
|
||||||
|
api.dependencies = dependencies.map(tag => tag.text.trim());
|
||||||
|
api.slots = slots.map(tag => splitText(tag.text));
|
||||||
|
api.parts = parts.map(tag => splitText(tag.text));
|
||||||
|
api.events = events.map(tag => splitText(tag.text));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.yellow(`Missing comment block for ${component.name} - skipping metadata`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = component.children
|
||||||
|
.filter(child => child.kindString === 'Property' && !child.flags.isStatic)
|
||||||
|
.filter(child => child.comment && child.comment.shortText); // only with comments
|
||||||
|
|
||||||
|
props.map(prop => {
|
||||||
|
const type = getTypeInfo(prop);
|
||||||
|
|
||||||
|
api.props.push({
|
||||||
|
name: prop.name,
|
||||||
|
description: prop.comment.shortText,
|
||||||
|
type,
|
||||||
|
defaultValue: prop.defaultValue
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const methods = component.children
|
||||||
|
.filter(child => child.kindString === 'Method' && !child.flags.isStatic)
|
||||||
|
.filter(child => child.signatures[0].comment && child.signatures[0].comment.shortText); // only with comments
|
||||||
|
|
||||||
|
methods.map(method => {
|
||||||
|
const signature = method.signatures[0];
|
||||||
|
const params = Array.isArray(signature.parameters)
|
||||||
|
? signature.parameters.map(param => {
|
||||||
|
const type = getTypeInfo(param);
|
||||||
|
return {
|
||||||
|
name: param.name,
|
||||||
|
type,
|
||||||
|
defaultValue: param.defaultValue
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
api.methods.push({
|
||||||
|
name: method.name,
|
||||||
|
description: signature.comment.shortText,
|
||||||
|
params
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSS custom properties
|
||||||
|
const stylesheet = path.resolve(path.dirname(api.file), path.parse(api.file).name + '.scss');
|
||||||
|
if (fs.existsSync(stylesheet)) {
|
||||||
|
const styles = fs.readFileSync(stylesheet, 'utf8');
|
||||||
|
const parsed = parse(styles);
|
||||||
|
const tags = parsed[0] ? parsed[0].tags : [];
|
||||||
|
const cssCustomProperties = tags
|
||||||
|
.filter(tag => tag.tag === 'prop')
|
||||||
|
.map(tag => api.cssCustomProperties.push({ name: tag.tag, description: tag.description }));
|
||||||
|
}
|
||||||
|
|
||||||
|
output.components.push(api);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate components.json
|
||||||
|
(async () => {
|
||||||
|
const filename = path.join('./dist/components.json');
|
||||||
|
const outputJson = JSON.stringify(output, null, 2);
|
||||||
|
|
||||||
|
await mkdirp(path.dirname(filename));
|
||||||
|
fs.writeFileSync(filename, outputJson, 'utf8');
|
||||||
|
console.log(chalk.green(`Successfully generated components.json 🏷\n`));
|
||||||
|
})();
|
|
@ -0,0 +1,11 @@
|
||||||
|
// This is a convenience file that exports everything and registers components automatically
|
||||||
|
import * as shoelace from './shoelace';
|
||||||
|
|
||||||
|
export * from './shoelace';
|
||||||
|
|
||||||
|
Object.keys(shoelace).map(key => {
|
||||||
|
const item = (shoelace as any)[key];
|
||||||
|
if (typeof item.register === 'function') {
|
||||||
|
item.register();
|
||||||
|
}
|
||||||
|
});
|
Plik diff jest za duży
Load Diff
|
@ -1,93 +0,0 @@
|
||||||
import { newE2EPage } from '@stencil/core/testing';
|
|
||||||
|
|
||||||
describe('<sl-alert>', () => {
|
|
||||||
it('should open when the open attribute is set', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-alert>This is an alert</sl-alert>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const alert = await page.find('sl-alert');
|
|
||||||
const base = await page.find('sl-alert >>> .alert');
|
|
||||||
const slShow = await alert.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await alert.spyOnEvent('sl-after-show');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
|
|
||||||
const showEventHappened = alert.waitForEvent('sl-after-show');
|
|
||||||
alert.setAttribute('open', '');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close when the open attribute is removed', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-alert open>This is an alert</sl-alert>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const alert = await page.find('sl-alert');
|
|
||||||
const base = await page.find('sl-alert >>> .alert');
|
|
||||||
const slHide = await alert.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await alert.spyOnEvent('sl-after-hide');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const hideEventHappened = alert.waitForEvent('sl-after-hide');
|
|
||||||
alert.removeAttribute('open');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await hideEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open with the show() method', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-alert>This is an alert</sl-alert>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const alert = await page.find('sl-alert');
|
|
||||||
const base = await page.find('sl-alert >>> .alert');
|
|
||||||
const slShow = await alert.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await alert.spyOnEvent('sl-after-show');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
|
|
||||||
const showEventHappened = alert.waitForEvent('sl-after-show');
|
|
||||||
await alert.callMethod('show');
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close with the hide() method', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-alert open>This is an alert</sl-alert>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const alert = await page.find('sl-alert');
|
|
||||||
const base = await page.find('sl-alert >>> .alert');
|
|
||||||
const slHide = await alert.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await alert.spyOnEvent('sl-after-hide');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const hideEventHappened = alert.waitForEvent('sl-after-hide');
|
|
||||||
await alert.callMethod('hide');
|
|
||||||
await hideEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,5 +1,5 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
@import 'mixins/hidden';
|
@use '../../styles/mixins/hide';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @prop --box-shadow: The alert's box shadow.
|
* @prop --box-shadow: The alert's box shadow.
|
||||||
|
@ -31,7 +31,7 @@
|
||||||
margin: inherit;
|
margin: inherit;
|
||||||
|
|
||||||
&:not(.alert--visible) {
|
&:not(.alert--visible) {
|
||||||
@include hidden;
|
@include hide.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./alert.scss';
|
||||||
|
|
||||||
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
|
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
|
||||||
|
|
||||||
|
@ -6,6 +7,8 @@ const toastStack = Object.assign(document.createElement('div'), { className: 'sl
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
* @status stable
|
* @status stable
|
||||||
*
|
*
|
||||||
|
* @dependency sl-icon-button
|
||||||
|
*
|
||||||
* @slot - The alert's content.
|
* @slot - The alert's content.
|
||||||
* @slot icon - An icon to show in the alert.
|
* @slot icon - An icon to show in the alert.
|
||||||
*
|
*
|
||||||
|
@ -13,65 +16,37 @@ const toastStack = Object.assign(document.createElement('div'), { className: 'sl
|
||||||
* @part icon - The container that wraps the alert icon.
|
* @part icon - The container that wraps the alert icon.
|
||||||
* @part message - The alert message.
|
* @part message - The alert message.
|
||||||
* @part close-button - The close button.
|
* @part close-button - The close button.
|
||||||
|
*
|
||||||
|
* @emit sl-show - Emitted when the alert opens. Calling `event.preventDefault()` will prevent it from being opened.
|
||||||
|
* @emit sl-after-show - Emitted after the alert opens and all transitions are complete.
|
||||||
|
* @emit sl-hide - Emitted when the alert closes. Calling `event.preventDefault()` will prevent it from being closed.
|
||||||
|
* @emit sl-after-hide - Emitted after the alert closes and all transitions are complete.
|
||||||
*/
|
*/
|
||||||
|
export default class SlAlert extends Shoemaker {
|
||||||
|
static tag = 'sl-alert';
|
||||||
|
static props = ['isVisible', 'open', 'closable', 'type', 'duration'];
|
||||||
|
static reflect = ['open', 'type'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
@Component({
|
private autoHideTimeout: any;
|
||||||
tag: 'sl-alert',
|
private isVisible = false;
|
||||||
styleUrl: 'alert.scss',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class Alert {
|
|
||||||
alert: HTMLElement;
|
|
||||||
autoHideTimeout: any;
|
|
||||||
|
|
||||||
@Element() host: HTMLSlAlertElement;
|
|
||||||
|
|
||||||
@State() isVisible = false;
|
|
||||||
|
|
||||||
/** Indicates whether or not the alert is open. You can use this in lieu of the show/hide methods. */
|
/** Indicates whether or not the alert is open. You can use this in lieu of the show/hide methods. */
|
||||||
@Prop({ mutable: true, reflect: true }) open = false;
|
open = false;
|
||||||
|
|
||||||
/** Set to true to make the alert closable. */
|
/** Makes the alert closable. */
|
||||||
@Prop({ reflect: true }) closable = false;
|
closable = false;
|
||||||
|
|
||||||
/** The type of alert. */
|
/** The type of alert. */
|
||||||
@Prop({ reflect: true }) type: 'primary' | 'success' | 'info' | 'warning' | 'danger' = 'primary';
|
type: 'primary' | 'success' | 'info' | 'warning' | 'danger' = 'primary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with the
|
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with
|
||||||
* alert before it closes (e.g. moves the mouse over it), the timer will restart.
|
* the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`.
|
||||||
*/
|
*/
|
||||||
@Prop() duration = Infinity;
|
duration = Infinity;
|
||||||
|
|
||||||
@Watch('open')
|
onConnect() {
|
||||||
handleOpenChange() {
|
|
||||||
this.open ? this.show() : this.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch('duration')
|
|
||||||
handleDurationChange() {
|
|
||||||
this.restartAutoHide();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Emitted when the alert opens. Calling `event.preventDefault()` will prevent it from being opened. */
|
|
||||||
@Event({ eventName: 'sl-show' }) slShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the alert opens and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-show' }) slAfterShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the alert closes. Calling `event.preventDefault()` will prevent it from being closed. */
|
|
||||||
@Event({ eventName: 'sl-hide' }) slHide: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the alert closes and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-hide' }) slAfterHide: EventEmitter;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleCloseClick = this.handleCloseClick.bind(this);
|
|
||||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
|
||||||
this.handleTransitionEnd = this.handleTransitionEnd.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillLoad() {
|
|
||||||
// Show on init if open
|
// Show on init if open
|
||||||
if (this.open) {
|
if (this.open) {
|
||||||
this.show();
|
this.show();
|
||||||
|
@ -79,14 +54,13 @@ export class Alert {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows the alert. */
|
/** Shows the alert. */
|
||||||
@Method()
|
show() {
|
||||||
async show() {
|
|
||||||
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
||||||
if (this.isVisible) {
|
if (this.isVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slShow = this.slShow.emit();
|
const slShow = this.emit('sl-show');
|
||||||
if (slShow.defaultPrevented) {
|
if (slShow.defaultPrevented) {
|
||||||
this.open = false;
|
this.open = false;
|
||||||
return;
|
return;
|
||||||
|
@ -101,14 +75,13 @@ export class Alert {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hides the alert */
|
/** Hides the alert */
|
||||||
@Method()
|
hide() {
|
||||||
async hide() {
|
|
||||||
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
||||||
if (!this.isVisible) {
|
if (!this.isVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slHide = this.slHide.emit();
|
const slHide = this.emit('sl-hide');
|
||||||
if (slHide.defaultPrevented) {
|
if (slHide.defaultPrevented) {
|
||||||
this.open = true;
|
this.open = true;
|
||||||
return;
|
return;
|
||||||
|
@ -120,28 +93,27 @@ export class Alert {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays the alert as a toast notification. This will move the alert out of its position in the DOM and, when
|
* Displays the alert as a toast notification. This will move the alert out of its position in the DOM
|
||||||
* dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by
|
* and, when dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse
|
||||||
* calling this method again. The returned promise will resolve after the alert is hidden.
|
* it by calling this method again. The returned promise will resolve after the alert is hidden.
|
||||||
*/
|
*/
|
||||||
@Method()
|
|
||||||
async toast() {
|
async toast() {
|
||||||
return new Promise<void>(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
if (!toastStack.parentElement) {
|
if (!toastStack.parentElement) {
|
||||||
document.body.append(toastStack);
|
document.body.append(toastStack);
|
||||||
}
|
}
|
||||||
|
|
||||||
toastStack.append(this.host);
|
toastStack.appendChild(this);
|
||||||
requestAnimationFrame(() => this.show());
|
requestAnimationFrame(() => this.show());
|
||||||
|
|
||||||
this.host.addEventListener(
|
this.addEventListener(
|
||||||
'sl-after-hide',
|
'sl-after-hide',
|
||||||
() => {
|
() => {
|
||||||
this.host.remove();
|
toastStack.removeChild(this);
|
||||||
resolve();
|
resolve();
|
||||||
|
|
||||||
// Remove the toast stack from the DOM when there are no more alerts
|
// Remove the toast stack from the DOM when there are no more alerts
|
||||||
if (toastStack.querySelector('sl-alert') === null) {
|
if (!toastStack.querySelector('sl-alert')) {
|
||||||
toastStack.remove();
|
toastStack.remove();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -150,6 +122,13 @@ export class Alert {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restartAutoHide() {
|
||||||
|
clearTimeout(this.autoHideTimeout);
|
||||||
|
if (this.open && this.duration < Infinity) {
|
||||||
|
this.autoHideTimeout = setTimeout(() => this.hide(), this.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleCloseClick() {
|
handleCloseClick() {
|
||||||
this.hide();
|
this.hide();
|
||||||
}
|
}
|
||||||
|
@ -164,23 +143,23 @@ export class Alert {
|
||||||
// Ensure we only emit one event when the target element is no longer visible
|
// Ensure we only emit one event when the target element is no longer visible
|
||||||
if (event.propertyName === 'opacity' && target.classList.contains('alert')) {
|
if (event.propertyName === 'opacity' && target.classList.contains('alert')) {
|
||||||
this.isVisible = this.open;
|
this.isVisible = this.open;
|
||||||
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
|
this.open ? this.emit('sl-after-show') : this.emit('sl-after-hide');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
restartAutoHide() {
|
watchOpen() {
|
||||||
clearTimeout(this.autoHideTimeout);
|
this.open ? this.show() : this.hide();
|
||||||
if (this.open && this.duration < Infinity) {
|
}
|
||||||
this.autoHideTimeout = setTimeout(() => this.hide(), this.duration);
|
|
||||||
}
|
watchDuration() {
|
||||||
|
this.restartAutoHide();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return html`
|
||||||
<div
|
<div
|
||||||
ref={el => (this.alert = el)}
|
|
||||||
part="base"
|
part="base"
|
||||||
class={{
|
class=${classMap({
|
||||||
alert: true,
|
alert: true,
|
||||||
'alert--open': this.open,
|
'alert--open': this.open,
|
||||||
'alert--visible': this.isVisible,
|
'alert--visible': this.isVisible,
|
||||||
|
@ -190,13 +169,13 @@ export class Alert {
|
||||||
'alert--info': this.type === 'info',
|
'alert--info': this.type === 'info',
|
||||||
'alert--warning': this.type === 'warning',
|
'alert--warning': this.type === 'warning',
|
||||||
'alert--danger': this.type === 'danger'
|
'alert--danger': this.type === 'danger'
|
||||||
}}
|
})}
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
aria-hidden={this.open ? 'false' : 'true'}
|
aria-hidden=${this.open ? 'false' : 'true'}
|
||||||
onMouseMove={this.handleMouseMove}
|
onmousemove=${this.handleMouseMove.bind(this)}
|
||||||
onTransitionEnd={this.handleTransitionEnd}
|
ontransitionend=${this.handleTransitionEnd.bind(this)}
|
||||||
>
|
>
|
||||||
<span part="icon" class="alert__icon">
|
<span part="icon" class="alert__icon">
|
||||||
<slot name="icon" />
|
<slot name="icon" />
|
||||||
|
@ -206,12 +185,14 @@ export class Alert {
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{this.closable && (
|
${this.closable
|
||||||
<span class="alert__close">
|
? html`
|
||||||
<sl-icon-button exportparts="base:close-button" name="x" onClick={this.handleCloseClick} />
|
<span class="alert__close">
|
||||||
</span>
|
<sl-icon-button exportparts="base:close-button" name="x" onclick=${this.handleCloseClick.bind(this)} />
|
||||||
)}
|
</span>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: contents;
|
display: contents;
|
||||||
|
|
|
@ -1,125 +1,101 @@
|
||||||
import { Component, Element, Event, EventEmitter, Method, Prop, Watch, h } from '@stencil/core';
|
import { html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./animation.scss';
|
||||||
import { animations } from './animations';
|
import { animations } from './animations';
|
||||||
import { easings } from './easings';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
* @status stable
|
* @status stable
|
||||||
*
|
*
|
||||||
* @slot - The element to animate. If multiple elements are to be animated, wrap them in a single container.
|
* @slot - The element to animate. If multiple elements are to be animated, wrap them in a single container.
|
||||||
|
*
|
||||||
|
* @emit sl-cancel - Emitted when the animation is canceled.
|
||||||
|
* @emit sl-finish - Emitted when the animation finishes.
|
||||||
|
* @emit sl-start - Emitted when the animation starts or restarts.
|
||||||
*/
|
*/
|
||||||
@Component({
|
export default class SlAnimation extends Shoemaker {
|
||||||
tag: 'sl-animation',
|
static tag = 'sl-animation';
|
||||||
styleUrl: 'animation.scss',
|
static props = [
|
||||||
shadow: true
|
'name',
|
||||||
})
|
'delay',
|
||||||
export class Animate {
|
'direction',
|
||||||
animation: Animation;
|
'duration',
|
||||||
hasStarted = false;
|
'easing',
|
||||||
|
'endDelay',
|
||||||
|
'fill',
|
||||||
|
'iterations',
|
||||||
|
'iterationStart',
|
||||||
|
'keyframes',
|
||||||
|
'playbackRate',
|
||||||
|
'pause'
|
||||||
|
];
|
||||||
|
static reflect = ['name', 'pause'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
@Element() host: HTMLSlAnimationElement;
|
private animation: Animation;
|
||||||
|
private defaultSlot: HTMLSlotElement;
|
||||||
|
private hasStarted = false;
|
||||||
|
|
||||||
/** The name of the built-in animation to use. For custom animations, use the `keyframes` prop. */
|
/** The name of the built-in animation to use. For custom animations, use the `keyframes` prop. */
|
||||||
@Prop() name = 'none';
|
name = 'none';
|
||||||
|
|
||||||
/** The number of milliseconds to delay the start of the animation. */
|
/** The number of milliseconds to delay the start of the animation. */
|
||||||
@Prop() delay = 0;
|
delay = 0;
|
||||||
|
|
||||||
/** Determines the direction of playback as well as the behavior when reaching the end of an iteration. */
|
/** Determines the direction of playback as well as the behavior when reaching the end of an iteration. */
|
||||||
@Prop() direction: PlaybackDirection = 'normal';
|
direction: PlaybackDirection = 'normal';
|
||||||
|
|
||||||
/** The number of milliseconds each iteration of the animation takes to complete. */
|
/** The number of milliseconds each iteration of the animation takes to complete. */
|
||||||
@Prop() duration = 1000;
|
duration = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The easing function to use for the animation. This can be a Shoelace easing function or a custom easing function
|
* 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)`.
|
* such as `cubic-bezier(0, 1, .76, 1.14)`.
|
||||||
*/
|
*/
|
||||||
@Prop() easing = 'linear';
|
easing = 'linear';
|
||||||
|
|
||||||
/** The number of milliseconds to delay after the active period of an animation sequence. */
|
/** The number of milliseconds to delay after the active period of an animation sequence. */
|
||||||
@Prop() endDelay = 0;
|
endDelay = 0;
|
||||||
|
|
||||||
/** Sets how the animation applies styles to its target before and after its execution. */
|
/** Sets how the animation applies styles to its target before and after its execution. */
|
||||||
@Prop() fill: FillMode = 'auto';
|
fill: FillMode = 'auto';
|
||||||
|
|
||||||
/** The number of iterations to run before the animation completes. Defaults to `Infinity`, which loops. */
|
/** The number of iterations to run before the animation completes. Defaults to `Infinity`, which loops. */
|
||||||
@Prop() iterations: number = Infinity;
|
iterations: number = Infinity;
|
||||||
|
|
||||||
/** The offset at which to start the animation, usually between 0 (start) and 1 (end). */
|
/** The offset at which to start the animation, usually between 0 (start) and 1 (end). */
|
||||||
@Prop() iterationStart = 0;
|
iterationStart = 0;
|
||||||
|
|
||||||
/** The keyframes to use for the animation. If this is set, `name` will be ignored. */
|
/** The keyframes to use for the animation. If this is set, `name` will be ignored. */
|
||||||
@Prop({ mutable: true }) keyframes: Keyframe[];
|
keyframes: Keyframe[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the animation's playback rate. The default is `1`, which plays the animation at a normal speed. Setting this
|
* 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
|
* 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.
|
* value can be changed without causing the animation to restart.
|
||||||
*/
|
*/
|
||||||
@Prop() playbackRate = 1;
|
playbackRate = 1;
|
||||||
|
|
||||||
/** Pauses the animation. The animation will resume when this prop is removed. */
|
/** Pauses the animation. The animation will resume when this prop is removed. */
|
||||||
@Prop() pause = false;
|
pause = false;
|
||||||
|
|
||||||
// Restart the animation when any of these properties change
|
onReady() {
|
||||||
@Watch('delay')
|
|
||||||
@Watch('direction')
|
|
||||||
@Watch('easing')
|
|
||||||
@Watch('endDelay')
|
|
||||||
@Watch('fill')
|
|
||||||
@Watch('iterations')
|
|
||||||
@Watch('iterationStart')
|
|
||||||
@Watch('keyframes')
|
|
||||||
@Watch('name')
|
|
||||||
handleRestartAnimation() {
|
|
||||||
this.createAnimation();
|
this.createAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Watch('pause')
|
onDisconnect() {
|
||||||
handlePauseChange() {
|
|
||||||
this.pause ? this.animation.pause() : this.animation.play();
|
|
||||||
|
|
||||||
if (!this.pause && !this.hasStarted) {
|
|
||||||
this.hasStarted = true;
|
|
||||||
this.slStart.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch('playbackRate')
|
|
||||||
handlePlaybackRateChange() {
|
|
||||||
this.animation.playbackRate = this.playbackRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Emitted when the animation is canceled. */
|
|
||||||
@Event({ eventName: 'sl-cancel' }) slCancel: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the animation finishes. */
|
|
||||||
@Event({ eventName: 'sl-finish' }) slFinish: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the animation starts or restarts. */
|
|
||||||
@Event({ eventName: 'sl-start' }) slStart: EventEmitter;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleAnimationFinish = this.handleAnimationFinish.bind(this);
|
|
||||||
this.handleAnimationCancel = this.handleAnimationCancel.bind(this);
|
|
||||||
this.handleSlotChange = this.handleSlotChange.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidLoad() {
|
|
||||||
this.createAnimation();
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
this.destroyAnimation();
|
this.destroyAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAnimationFinish() {
|
handleAnimationFinish() {
|
||||||
this.slFinish.emit();
|
this.emit('sl-finish');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAnimationCancel() {
|
handleAnimationCancel() {
|
||||||
this.slCancel.emit();
|
this.emit('sl-cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePlaybackRateChange() {
|
||||||
|
this.animation.playbackRate = this.playbackRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSlotChange() {
|
handleSlotChange() {
|
||||||
|
@ -128,10 +104,9 @@ export class Animate {
|
||||||
}
|
}
|
||||||
|
|
||||||
createAnimation() {
|
createAnimation() {
|
||||||
const easing = easings[this.easing] || this.easing;
|
const easing = animations.easings[this.easing] || this.easing;
|
||||||
const keyframes: Keyframe[] = this.keyframes ? this.keyframes : animations[this.name];
|
const keyframes: Keyframe[] = this.keyframes ? this.keyframes : (animations as any)[this.name];
|
||||||
const slot = this.host.shadowRoot.querySelector('slot');
|
const element = this.defaultSlot.assignedElements({ flatten: true })[0] as HTMLElement;
|
||||||
const element = slot.assignedElements({ flatten: true })[0] as HTMLElement;
|
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
|
@ -156,7 +131,7 @@ export class Animate {
|
||||||
this.animation.pause();
|
this.animation.pause();
|
||||||
} else {
|
} else {
|
||||||
this.hasStarted = true;
|
this.hasStarted = true;
|
||||||
this.slStart.emit();
|
this.emit('sl-start');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,52 +140,87 @@ export class Animate {
|
||||||
this.animation.cancel();
|
this.animation.cancel();
|
||||||
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
|
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
|
||||||
this.animation.removeEventListener('finish', this.handleAnimationFinish);
|
this.animation.removeEventListener('finish', this.handleAnimationFinish);
|
||||||
this.animation = null;
|
|
||||||
this.hasStarted = false;
|
this.hasStarted = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restart the animation when any of these properties change
|
||||||
|
watchDelay() {
|
||||||
|
this.createAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDirection() {
|
||||||
|
this.createAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEasing() {
|
||||||
|
this.createAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEndDelay() {
|
||||||
|
this.createAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchFill() {
|
||||||
|
this.createAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchIterations() {
|
||||||
|
this.createAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchIterationStart() {
|
||||||
|
this.createAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchKeyframes() {
|
||||||
|
this.createAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchName() {
|
||||||
|
this.createAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchPause() {
|
||||||
|
this.pause ? this.animation.pause() : this.animation.play();
|
||||||
|
|
||||||
|
if (!this.pause && !this.hasStarted) {
|
||||||
|
this.hasStarted = true;
|
||||||
|
this.emit('sl-start');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchPlaybackRate() {
|
||||||
|
this.animation.playbackRate = this.playbackRate;
|
||||||
|
}
|
||||||
|
|
||||||
/** Clears all KeyframeEffects caused by this animation and aborts its playback. */
|
/** Clears all KeyframeEffects caused by this animation and aborts its playback. */
|
||||||
@Method()
|
cancel() {
|
||||||
async cancel() {
|
|
||||||
try {
|
try {
|
||||||
this.animation.cancel();
|
this.animation.cancel();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
|
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
|
||||||
@Method()
|
finish() {
|
||||||
async finish() {
|
|
||||||
try {
|
try {
|
||||||
this.animation.finish();
|
this.animation.finish();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gets a list of all supported animation names. */
|
|
||||||
@Method()
|
|
||||||
async getAnimationNames() {
|
|
||||||
return Object.entries(animations).map(([name]) => name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gets a list of all supported easing function names. */
|
|
||||||
@Method()
|
|
||||||
async getEasingNames() {
|
|
||||||
return Object.entries(easings).map(([name]) => name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gets the current time of the animation in milliseconds. */
|
/** Gets the current time of the animation in milliseconds. */
|
||||||
@Method()
|
getCurrentTime() {
|
||||||
async getCurrentTime() {
|
|
||||||
return this.animation.currentTime;
|
return this.animation.currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the current time of the animation in milliseconds. */
|
/** Sets the current time of the animation in milliseconds. */
|
||||||
@Method()
|
setCurrentTime(time: number) {
|
||||||
async setCurrentTime(time: number) {
|
|
||||||
this.animation.currentTime = time;
|
this.animation.currentTime = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <slot onSlotchange={this.handleSlotChange} />;
|
return html`
|
||||||
|
<slot ref=${(el: HTMLSlotElement) => (this.defaultSlot = el)} onslotchange=${this.handleSlotChange.bind(this)} />
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import * as animations from '@shoelace-style/animations';
|
||||||
|
|
||||||
|
export { animations };
|
||||||
|
|
||||||
|
/** Gets a list of all supported animation names. */
|
||||||
|
export function getAnimationNames() {
|
||||||
|
return Object.entries(animations)
|
||||||
|
.filter(([name]) => name !== 'easings')
|
||||||
|
.map(([name]) => name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets a list of all supported easing function names. */
|
||||||
|
export function getEasingNames() {
|
||||||
|
return Object.entries(animations.easings).map(([name]) => name);
|
||||||
|
}
|
|
@ -1 +0,0 @@
|
||||||
export * as animations from '@shoelace-style/animations';
|
|
|
@ -1,31 +0,0 @@
|
||||||
export const easings = {
|
|
||||||
linear: 'linear',
|
|
||||||
ease: 'ease',
|
|
||||||
easeIn: 'ease-in',
|
|
||||||
easeOut: 'ease-out',
|
|
||||||
easeInOut: 'ease-in-out',
|
|
||||||
easeInSine: 'cubic-bezier(0.47, 0, 0.745, 0.715)',
|
|
||||||
easeOutSine: 'cubic-bezier(0.39, 0.575, 0.565, 1)',
|
|
||||||
easeInOutSine: 'cubic-bezier(0.445, 0.05, 0.55, 0.95)',
|
|
||||||
easeInQuad: 'cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
|
||||||
easeOutQuad: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
||||||
easeInOutQuad: 'cubic-bezier(0.455, 0.03, 0.515, 0.955)',
|
|
||||||
easeInCubic: 'cubic-bezier(0.55, 0.055, 0.675, 0.19)',
|
|
||||||
easeOutCubic: 'cubic-bezier(0.215, 0.61, 0.355, 1)',
|
|
||||||
easeInOutCubic: 'cubic-bezier(0.645, 0.045, 0.355, 1)',
|
|
||||||
easeInQuart: 'cubic-bezier(0.895, 0.03, 0.685, 0.22)',
|
|
||||||
easeOutQuart: 'cubic-bezier(0.165, 0.84, 0.44, 1)',
|
|
||||||
easeInOutQuart: 'cubic-bezier(0.77, 0, 0.175, 1)',
|
|
||||||
easeInQuint: 'cubic-bezier(0.755, 0.05, 0.855, 0.06)',
|
|
||||||
easeOutQuint: 'cubic-bezier(0.23, 1, 0.32, 1)',
|
|
||||||
easeInOutQuint: 'cubic-bezier(0.86, 0, 0.07, 1)',
|
|
||||||
easeInExpo: 'cubic-bezier(0.95, 0.05, 0.795, 0.035)',
|
|
||||||
easeOutExpo: 'cubic-bezier(0.19, 1, 0.22, 1)',
|
|
||||||
easeInOutExpo: 'cubic-bezier(1, 0, 0, 1)',
|
|
||||||
easeInCirc: 'cubic-bezier(0.6, 0.04, 0.98, 0.335)',
|
|
||||||
easeOutCirc: 'cubic-bezier(0.075, 0.82, 0.165, 1)',
|
|
||||||
easeInOutCirc: 'cubic-bezier(0.785, 0.135, 0.15, 0.86)',
|
|
||||||
easeInBack: 'cubic-bezier(0.6, -0.28, 0.735, 0.045)',
|
|
||||||
easeOutBack: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
|
||||||
easeInOutBack: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)'
|
|
||||||
};
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @prop --size: The size of the avatar.
|
* @prop --size: The size of the avatar.
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./avatar.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @since 2.0
|
||||||
|
* @status stable
|
||||||
|
*
|
||||||
|
* @dependency sl-icon
|
||||||
|
*
|
||||||
|
* @slot icon - The default icon to use when no image or initials are present.
|
||||||
|
*
|
||||||
|
* @part base - The component's base wrapper.
|
||||||
|
* @part icon - The container that wraps the avatar icon.
|
||||||
|
* @part initials - The container that wraps the avatar initials.
|
||||||
|
* @part image - The avatar image.
|
||||||
|
*/
|
||||||
|
export default class SlAvatar extends Shoemaker {
|
||||||
|
static tag = 'sl-avatar';
|
||||||
|
static props = ['hasError', 'image', 'alt', 'initials', 'shape'];
|
||||||
|
static reflect = ['shape'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
private hasError = false;
|
||||||
|
|
||||||
|
/** The image source to use for the avatar. */
|
||||||
|
image = '';
|
||||||
|
|
||||||
|
/** Alternative text for the image. */
|
||||||
|
alt = '';
|
||||||
|
|
||||||
|
/** Initials to use as a fallback when no image is available (1-2 characters max recommended). */
|
||||||
|
initials = '';
|
||||||
|
|
||||||
|
/** The shape of the avatar. */
|
||||||
|
shape: 'circle' | 'square' | 'rounded' = 'circle';
|
||||||
|
|
||||||
|
render() {
|
||||||
|
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="image"
|
||||||
|
aria-label=${this.alt}
|
||||||
|
>
|
||||||
|
${this.initials
|
||||||
|
? html` <div part="initials" class="avatar__initials">${this.initials}</div> `
|
||||||
|
: html`
|
||||||
|
<div part="icon" class="avatar__icon">
|
||||||
|
<slot name="icon">
|
||||||
|
<sl-icon name="person-fill"></sl-icon>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
${this.image && !this.hasError
|
||||||
|
? html`
|
||||||
|
<img part="image" class="avatar__image" src="${this.image}" onerror="${() => (this.hasError = true)}" />
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,76 +0,0 @@
|
||||||
import { Component, Prop, State, h } from '@stencil/core';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @since 2.0
|
|
||||||
* @status stable
|
|
||||||
*
|
|
||||||
* @slot icon - The default icon to use when no image or initials are present.
|
|
||||||
*
|
|
||||||
* @part base - The component's base wrapper.
|
|
||||||
* @part icon - The container that wraps the avatar icon.
|
|
||||||
* @part initials - The container that wraps the avatar initials.
|
|
||||||
* @part image - The avatar image.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'sl-avatar',
|
|
||||||
styleUrl: 'avatar.scss',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class Avatar {
|
|
||||||
@State() hasError = false;
|
|
||||||
|
|
||||||
/** The image source to use for the avatar. */
|
|
||||||
@Prop() image = '';
|
|
||||||
|
|
||||||
/** Alternative text for the image. */
|
|
||||||
@Prop() alt = '';
|
|
||||||
|
|
||||||
/** Initials to use as a fallback when no image is available (1-2 characters max recommended). */
|
|
||||||
@Prop() initials = '';
|
|
||||||
|
|
||||||
/** The shape of the avatar. */
|
|
||||||
@Prop() shape: 'circle' | 'square' | 'rounded' = 'circle';
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleImageError = this.handleImageError.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageError() {
|
|
||||||
this.hasError = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
part="base"
|
|
||||||
class={{
|
|
||||||
avatar: true,
|
|
||||||
'avatar--circle': this.shape === 'circle',
|
|
||||||
'avatar--rounded': this.shape === 'rounded',
|
|
||||||
'avatar--square': this.shape === 'square'
|
|
||||||
}}
|
|
||||||
role="image"
|
|
||||||
aria-label={this.alt}
|
|
||||||
>
|
|
||||||
{!this.initials && (
|
|
||||||
<div part="icon" class="avatar__icon">
|
|
||||||
<slot name="icon">
|
|
||||||
<sl-icon name="person-fill" />
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{this.initials && (
|
|
||||||
<div part="initials" class="avatar__initials">
|
|
||||||
{this.initials}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{this.image && !this.hasError && (
|
|
||||||
<img part="image" class="avatar__image" src={this.image} onError={this.handleImageError} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Component, Prop, h } from '@stencil/core';
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./badge.scss';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
|
@ -8,33 +9,27 @@ import { Component, Prop, h } from '@stencil/core';
|
||||||
*
|
*
|
||||||
* @part base - The base wrapper
|
* @part base - The base wrapper
|
||||||
*/
|
*/
|
||||||
|
export default class SlBadge extends Shoemaker {
|
||||||
@Component({
|
static tag = 'sl-badge';
|
||||||
tag: 'sl-badge',
|
static props = ['type', 'pill', 'pulse'];
|
||||||
styleUrl: 'badge.scss',
|
static reflect = ['type', 'pill', 'pulse'];
|
||||||
shadow: true
|
static styles = styles;
|
||||||
})
|
|
||||||
export class Badge {
|
|
||||||
badge: HTMLElement;
|
|
||||||
|
|
||||||
/** The badge's type. */
|
/** The badge's type. */
|
||||||
@Prop() type: 'primary' | 'success' | 'info' | 'warning' | 'danger' = 'primary';
|
type: 'primary' | 'success' | 'info' | 'warning' | 'danger' = 'primary';
|
||||||
|
|
||||||
/** Set to true to draw a pill-style badge with rounded edges. */
|
/** Draws a pill-style badge with rounded edges. */
|
||||||
@Prop() pill = false;
|
pill = false;
|
||||||
|
|
||||||
/** Set to true to make the badge pulsate to draw attention. */
|
/** Makes the badge pulsate to draw attention. */
|
||||||
@Prop() pulse = false;
|
pulse = false;
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return html`
|
||||||
<span
|
<span
|
||||||
ref={el => (this.badge = el)}
|
|
||||||
part="base"
|
part="base"
|
||||||
class={{
|
class=${classMap({
|
||||||
badge: true,
|
badge: true,
|
||||||
|
|
||||||
// Types
|
|
||||||
'badge--primary': this.type === 'primary',
|
'badge--primary': this.type === 'primary',
|
||||||
'badge--success': this.type === 'success',
|
'badge--success': this.type === 'success',
|
||||||
'badge--info': this.type === 'info',
|
'badge--info': this.type === 'info',
|
||||||
|
@ -42,11 +37,11 @@ export class Badge {
|
||||||
'badge--danger': this.type === 'danger',
|
'badge--danger': this.type === 'danger',
|
||||||
'badge--pill': this.pill,
|
'badge--pill': this.pill,
|
||||||
'badge--pulse': this.pulse
|
'badge--pulse': this.pulse
|
||||||
}}
|
})}
|
||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -36,7 +36,7 @@ sl-button-group {
|
||||||
margin-left: calc(-1 * var(--sl-input-border-width));
|
margin-left: calc(-1 * var(--sl-input-border-width));
|
||||||
|
|
||||||
// Add a visual separator between solid buttons
|
// Add a visual separator between solid buttons
|
||||||
&:not([type='default'])::part(base):not(:hover):not(:active):not(:focus)::after {
|
&:not([type='default'])::part(base):not(:hover):not(:active):not(:focus):after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Component, Prop, h } from '@stencil/core';
|
import { html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./button-group.scss';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
|
@ -9,28 +10,25 @@ import { Component, Prop, h } from '@stencil/core';
|
||||||
* @part base - The component's base wrapper.
|
* @part base - The component's base wrapper.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@Component({
|
export default class SlButtonGroup extends Shoemaker {
|
||||||
tag: 'sl-button-group',
|
static tag = 'sl-button-group';
|
||||||
styleUrl: 'button-group.scss',
|
static props = ['label'];
|
||||||
shadow: true
|
static styles = styles;
|
||||||
})
|
|
||||||
export class ButtonGroup {
|
private buttonGroup: HTMLElement;
|
||||||
buttonGroup: HTMLElement;
|
|
||||||
|
|
||||||
/** A label to use for the button group's `aria-label` attribute. */
|
/** A label to use for the button group's `aria-label` attribute. */
|
||||||
@Prop() label = '';
|
label = '';
|
||||||
|
|
||||||
connectedCallback() {
|
onReady() {
|
||||||
this.handleFocus = this.handleFocus.bind(this);
|
this.handleFocus = this.handleFocus.bind(this);
|
||||||
this.handleBlur = this.handleBlur.bind(this);
|
this.handleBlur = this.handleBlur.bind(this);
|
||||||
}
|
|
||||||
|
|
||||||
componentDidLoad() {
|
|
||||||
this.buttonGroup.addEventListener('sl-focus', this.handleFocus);
|
this.buttonGroup.addEventListener('sl-focus', this.handleFocus);
|
||||||
this.buttonGroup.addEventListener('sl-blur', this.handleBlur);
|
this.buttonGroup.addEventListener('sl-blur', this.handleBlur);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
onDisconnect() {
|
||||||
this.buttonGroup.removeEventListener('sl-focus', this.handleFocus);
|
this.buttonGroup.removeEventListener('sl-focus', this.handleFocus);
|
||||||
this.buttonGroup.removeEventListener('sl-blur', this.handleBlur);
|
this.buttonGroup.removeEventListener('sl-blur', this.handleBlur);
|
||||||
}
|
}
|
||||||
|
@ -46,10 +44,15 @@ export class ButtonGroup {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return html`
|
||||||
<div ref={el => (this.buttonGroup = el)} part="base" class="button-group" aria-label={this.label}>
|
<div
|
||||||
|
ref=${(el: HTMLElement) => (this.buttonGroup = el)}
|
||||||
|
part="base"
|
||||||
|
class="button-group"
|
||||||
|
aria-label=${this.label}
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,63 +0,0 @@
|
||||||
import { newE2EPage } from '@stencil/core/testing';
|
|
||||||
|
|
||||||
describe('<sl-button>', () => {
|
|
||||||
it('should emit sl-focus when gaining focus', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-button>Button</sl-button>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const button = await page.find('sl-button');
|
|
||||||
const slFocus = await button.spyOnEvent('sl-focus');
|
|
||||||
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
expect(slFocus).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-blur when losing focus', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-button>Button</sl-button>
|
|
||||||
<button>Native Button</button>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const button = await page.find('sl-button');
|
|
||||||
const nativeButton = await page.find('button');
|
|
||||||
const slBlur = await button.spyOnEvent('sl-blur');
|
|
||||||
|
|
||||||
await button.click();
|
|
||||||
await nativeButton.click();
|
|
||||||
|
|
||||||
expect(slBlur).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-focus when calling setFocus()', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-button>Button</sl-button>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const button = await page.find('sl-button');
|
|
||||||
const slFocus = await button.spyOnEvent('sl-focus');
|
|
||||||
|
|
||||||
await button.callMethod('setFocus');
|
|
||||||
|
|
||||||
expect(slFocus).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-blur when calling removeFocus()', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-button>Button</sl-button>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const button = await page.find('sl-button');
|
|
||||||
const slBlur = await button.spyOnEvent('sl-blur');
|
|
||||||
|
|
||||||
await button.callMethod('setFocus');
|
|
||||||
await button.callMethod('removeFocus');
|
|
||||||
|
|
||||||
expect(slBlur).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./button.scss';
|
||||||
|
import { hasSlot } from '../../internal/slot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @tag sl-button
|
||||||
|
* @since 2.0
|
||||||
|
* @status stable
|
||||||
|
*
|
||||||
|
* @dependency sl-spinner
|
||||||
|
*
|
||||||
|
* @slot - The button's label.
|
||||||
|
* @slot prefix - Used to prepend an icon or similar element to the button.
|
||||||
|
* @slot suffix - Used to append an icon or similar element to the button.
|
||||||
|
*
|
||||||
|
* @part base - The component's base wrapper.
|
||||||
|
* @part prefix - The prefix container.
|
||||||
|
* @part label - The button's label.
|
||||||
|
* @part suffix - The suffix container.
|
||||||
|
* @part caret - The button's caret.
|
||||||
|
*
|
||||||
|
* @emit sl-blur - Emitted when the button loses focus.
|
||||||
|
* @emit sl-focus - Emitted when the button gains focus.
|
||||||
|
*/
|
||||||
|
export default class SlButton extends Shoemaker {
|
||||||
|
static tag = 'sl-button';
|
||||||
|
static props = [
|
||||||
|
'hasFocus',
|
||||||
|
'hasLabel',
|
||||||
|
'hasPrefix',
|
||||||
|
'hasSuffix',
|
||||||
|
'type',
|
||||||
|
'size',
|
||||||
|
'caret',
|
||||||
|
'disabled',
|
||||||
|
'loading',
|
||||||
|
'pill',
|
||||||
|
'circle',
|
||||||
|
'submit',
|
||||||
|
'name',
|
||||||
|
'value',
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'download'
|
||||||
|
];
|
||||||
|
static reflect = ['type', 'size', 'caret', 'disabled', 'loading', 'pill', 'circle', 'submit'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
private button: HTMLButtonElement | HTMLLinkElement;
|
||||||
|
private hasFocus = false;
|
||||||
|
private hasLabel = false;
|
||||||
|
private hasPrefix = false;
|
||||||
|
private hasSuffix = false;
|
||||||
|
|
||||||
|
/** The button's type. */
|
||||||
|
type: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger' | 'text' = 'default';
|
||||||
|
|
||||||
|
/** The button's size. */
|
||||||
|
size: 'small' | 'medium' | 'large' = 'medium';
|
||||||
|
|
||||||
|
/** Draws the button with a caret for use with dropdowns, popovers, etc. */
|
||||||
|
caret = false;
|
||||||
|
|
||||||
|
/** Disables the button. */
|
||||||
|
disabled = false;
|
||||||
|
|
||||||
|
/** Draws the button in a loading state. */
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
/** Draws a pill-style button with rounded edges. */
|
||||||
|
pill = false;
|
||||||
|
|
||||||
|
/** Draws a circle button. */
|
||||||
|
circle = false;
|
||||||
|
|
||||||
|
/** Indicates if activating the button should submit the form. Ignored when `href` is set. */
|
||||||
|
submit = false;
|
||||||
|
|
||||||
|
/** An optional name for the button. Ignored when `href` is set. */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** An optional value for the button. Ignored when `href` is set. */
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
||||||
|
href: string;
|
||||||
|
|
||||||
|
/** Tells the browser where to open the link. Only used when `href` is set. */
|
||||||
|
target: '_blank' | '_parent' | '_self' | '_top';
|
||||||
|
|
||||||
|
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
|
||||||
|
download: string;
|
||||||
|
|
||||||
|
onConnect() {
|
||||||
|
this.handleSlotChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** setFocus - Sets focus on the button. */
|
||||||
|
setFocus(options?: FocusOptions) {
|
||||||
|
this.button.focus(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** removeFocus - Removes focus from the button. */
|
||||||
|
removeFocus() {
|
||||||
|
this.button.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSlotChange() {
|
||||||
|
this.hasLabel = hasSlot(this);
|
||||||
|
this.hasPrefix = hasSlot(this, 'prefix');
|
||||||
|
this.hasSuffix = hasSlot(this, 'suffix');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlur() {
|
||||||
|
this.hasFocus = false;
|
||||||
|
this.emit('sl-blur');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFocus() {
|
||||||
|
this.hasFocus = true;
|
||||||
|
this.emit('sl-focus');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick(event: MouseEvent) {
|
||||||
|
if (this.disabled || this.loading) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const isLink = this.href ? true : false;
|
||||||
|
|
||||||
|
const interior = html`
|
||||||
|
<span part="prefix" class="button__prefix">
|
||||||
|
<slot onslotchange=${this.handleSlotChange.bind(this)} name="prefix" />
|
||||||
|
</span>
|
||||||
|
<span part="label" class="button__label">
|
||||||
|
<slot onslotchange=${this.handleSlotChange.bind(this)} />
|
||||||
|
</span>
|
||||||
|
<span part="suffix" class="button__suffix">
|
||||||
|
<slot onslotchange=${this.handleSlotChange.bind(this)} name="suffix" />
|
||||||
|
</span>
|
||||||
|
${this.caret
|
||||||
|
? html`
|
||||||
|
<span part="caret" class="button__caret">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
${this.loading ? html`<sl-spinner />` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const button = html`
|
||||||
|
<button
|
||||||
|
ref=${(el: HTMLButtonElement) => (this.button = el)}
|
||||||
|
part="base"
|
||||||
|
class=${classMap({
|
||||||
|
button: true,
|
||||||
|
'button--default': this.type === 'default',
|
||||||
|
'button--primary': this.type === 'primary',
|
||||||
|
'button--success': this.type === 'success',
|
||||||
|
'button--info': this.type === 'info',
|
||||||
|
'button--warning': this.type === 'warning',
|
||||||
|
'button--danger': this.type === 'danger',
|
||||||
|
'button--text': this.type === '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--pill': this.pill,
|
||||||
|
'button--has-label': this.hasLabel,
|
||||||
|
'button--has-prefix': this.hasPrefix,
|
||||||
|
'button--has-suffix': this.hasSuffix
|
||||||
|
})}
|
||||||
|
disabled=${this.disabled ? true : null}
|
||||||
|
type=${this.submit ? 'submit' : 'button'}
|
||||||
|
name=${this.name}
|
||||||
|
value=${this.value}
|
||||||
|
onblur=${this.handleBlur.bind(this)}
|
||||||
|
onfocus=${this.handleFocus.bind(this)}
|
||||||
|
onclick=${this.handleClick.bind(this)}
|
||||||
|
>
|
||||||
|
${interior}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const link = html`
|
||||||
|
<a
|
||||||
|
ref=${(el: HTMLLinkElement) => (this.button = el)}
|
||||||
|
part="base"
|
||||||
|
class=${classMap({
|
||||||
|
button: true,
|
||||||
|
'button--default': this.type === 'default',
|
||||||
|
'button--primary': this.type === 'primary',
|
||||||
|
'button--success': this.type === 'success',
|
||||||
|
'button--info': this.type === 'info',
|
||||||
|
'button--warning': this.type === 'warning',
|
||||||
|
'button--danger': this.type === 'danger',
|
||||||
|
'button--text': this.type === '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--pill': this.pill,
|
||||||
|
'button--has-label': this.hasLabel,
|
||||||
|
'button--has-prefix': this.hasPrefix,
|
||||||
|
'button--has-suffix': this.hasSuffix
|
||||||
|
})}
|
||||||
|
href=${this.href}
|
||||||
|
target=${this.target ? this.target : null}
|
||||||
|
download=${this.download ? this.download : null}
|
||||||
|
rel=${this.target ? 'noreferrer noopener' : null}
|
||||||
|
onblur=${this.handleBlur.bind(this)}
|
||||||
|
onfocus=${this.handleFocus.bind(this)}
|
||||||
|
onclick=${this.handleClick.bind(this)}
|
||||||
|
>
|
||||||
|
${interior}
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return isLink ? link : button;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,202 +0,0 @@
|
||||||
import { Component, Element, Event, EventEmitter, Method, Prop, State, h } from '@stencil/core';
|
|
||||||
import { hasSlot } from '../../utilities/slot';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @since 2.0
|
|
||||||
* @status stable
|
|
||||||
*
|
|
||||||
* @slot - The button's label.
|
|
||||||
* @slot prefix - Used to prepend an icon or similar element to the button.
|
|
||||||
* @slot suffix - Used to append an icon or similar element to the button.
|
|
||||||
*
|
|
||||||
* @part base - The component's base wrapper.
|
|
||||||
* @part prefix - The prefix container.
|
|
||||||
* @part label - The button's label.
|
|
||||||
* @part suffix - The suffix container.
|
|
||||||
* @part caret - The button's caret.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'sl-button',
|
|
||||||
styleUrl: 'button.scss',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class Button {
|
|
||||||
button: HTMLButtonElement;
|
|
||||||
|
|
||||||
@Element() host: HTMLSlButtonElement;
|
|
||||||
|
|
||||||
@State() hasFocus = false;
|
|
||||||
@State() hasLabel = false;
|
|
||||||
@State() hasPrefix = false;
|
|
||||||
@State() hasSuffix = false;
|
|
||||||
|
|
||||||
/** The button's type. */
|
|
||||||
@Prop({ reflect: true }) type: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger' | 'text' = 'default';
|
|
||||||
|
|
||||||
/** The button's size. */
|
|
||||||
@Prop({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
|
||||||
|
|
||||||
/** Set to true to draw the button with a caret for use with dropdowns, popovers, etc. */
|
|
||||||
@Prop() caret = false;
|
|
||||||
|
|
||||||
/** Set to true to disable the button. */
|
|
||||||
@Prop({ reflect: true }) disabled = false;
|
|
||||||
|
|
||||||
/** Set to true to draw the button in a loading state. */
|
|
||||||
@Prop({ reflect: true }) loading = false;
|
|
||||||
|
|
||||||
/** Set to true to draw a pill-style button with rounded edges. */
|
|
||||||
@Prop({ reflect: true }) pill = false;
|
|
||||||
|
|
||||||
/** Set to true to draw a circle button. */
|
|
||||||
@Prop({ reflect: true }) circle = false;
|
|
||||||
|
|
||||||
/** Indicates if activating the button should submit the form. Ignored when `href` is set. */
|
|
||||||
@Prop({ reflect: true }) submit = false;
|
|
||||||
|
|
||||||
/** An optional name for the button. Ignored when `href` is set. */
|
|
||||||
@Prop() name: string;
|
|
||||||
|
|
||||||
/** An optional value for the button. Ignored when `href` is set. */
|
|
||||||
@Prop() value: string;
|
|
||||||
|
|
||||||
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
|
|
||||||
@Prop() href: string;
|
|
||||||
|
|
||||||
/** Tells the browser where to open the link. Only used when `href` is set. */
|
|
||||||
@Prop() target: '_blank' | '_parent' | '_self' | '_top';
|
|
||||||
|
|
||||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
|
|
||||||
@Prop() download: string;
|
|
||||||
|
|
||||||
/** Emitted when the button loses focus. */
|
|
||||||
@Event({ eventName: 'sl-blur' }) slBlur: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the button gains focus. */
|
|
||||||
@Event({ eventName: 'sl-focus' }) slFocus: EventEmitter;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleBlur = this.handleBlur.bind(this);
|
|
||||||
this.handleFocus = this.handleFocus.bind(this);
|
|
||||||
this.handleClick = this.handleClick.bind(this);
|
|
||||||
this.handleSlotChange = this.handleSlotChange.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillLoad() {
|
|
||||||
this.handleSlotChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets focus on the button. */
|
|
||||||
@Method()
|
|
||||||
async setFocus(options?: FocusOptions) {
|
|
||||||
this.button.focus(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Removes focus from the button. */
|
|
||||||
@Method()
|
|
||||||
async removeFocus() {
|
|
||||||
this.button.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSlotChange() {
|
|
||||||
this.hasLabel = hasSlot(this.host);
|
|
||||||
this.hasPrefix = hasSlot(this.host, 'prefix');
|
|
||||||
this.hasSuffix = hasSlot(this.host, 'suffix');
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBlur() {
|
|
||||||
this.hasFocus = false;
|
|
||||||
this.slBlur.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFocus() {
|
|
||||||
this.hasFocus = true;
|
|
||||||
this.slFocus.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(event: MouseEvent) {
|
|
||||||
if (this.disabled || this.loading) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const isLink = this.href ? true : false;
|
|
||||||
const isButton = !isLink;
|
|
||||||
const Button = isLink ? 'a' : 'button';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={el => (this.button = el)}
|
|
||||||
part="base"
|
|
||||||
class={{
|
|
||||||
button: true,
|
|
||||||
|
|
||||||
// Types
|
|
||||||
'button--default': this.type === 'default',
|
|
||||||
'button--primary': this.type === 'primary',
|
|
||||||
'button--success': this.type === 'success',
|
|
||||||
'button--info': this.type === 'info',
|
|
||||||
'button--warning': this.type === 'warning',
|
|
||||||
'button--danger': this.type === 'danger',
|
|
||||||
'button--text': this.type === 'text',
|
|
||||||
|
|
||||||
// Sizes
|
|
||||||
'button--small': this.size === 'small',
|
|
||||||
'button--medium': this.size === 'medium',
|
|
||||||
'button--large': this.size === 'large',
|
|
||||||
|
|
||||||
// Modifiers
|
|
||||||
'button--caret': this.caret,
|
|
||||||
'button--circle': this.circle,
|
|
||||||
'button--disabled': this.disabled,
|
|
||||||
'button--focused': this.hasFocus,
|
|
||||||
'button--loading': this.loading,
|
|
||||||
'button--pill': this.pill,
|
|
||||||
'button--has-label': this.hasLabel,
|
|
||||||
'button--has-prefix': this.hasPrefix,
|
|
||||||
'button--has-suffix': this.hasSuffix
|
|
||||||
}}
|
|
||||||
disabled={isButton ? this.disabled : null}
|
|
||||||
type={isButton ? (this.submit ? 'submit' : 'button') : null}
|
|
||||||
name={isButton ? this.name : null}
|
|
||||||
value={isButton ? this.value : null}
|
|
||||||
href={isLink && this.href}
|
|
||||||
target={isLink && this.target ? this.target : null}
|
|
||||||
download={isLink && this.download ? this.download : null}
|
|
||||||
rel={isLink && this.target ? 'noreferrer noopener' : null}
|
|
||||||
onBlur={this.handleBlur}
|
|
||||||
onFocus={this.handleFocus}
|
|
||||||
onClick={this.handleClick}
|
|
||||||
>
|
|
||||||
<span part="prefix" class="button__prefix">
|
|
||||||
<slot onSlotchange={this.handleSlotChange} name="prefix" />
|
|
||||||
</span>
|
|
||||||
<span part="label" class="button__label">
|
|
||||||
<slot onSlotchange={this.handleSlotChange} />
|
|
||||||
</span>
|
|
||||||
<span part="suffix" class="button__suffix">
|
|
||||||
<slot onSlotchange={this.handleSlotChange} name="suffix" />
|
|
||||||
</span>
|
|
||||||
{this.caret && (
|
|
||||||
<span part="caret" class="button__caret">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{this.loading && <sl-spinner />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @prop --border-color: The card's border color, including borders that occur inside the card.
|
* @prop --border-color: The card's border color, including borders that occur inside the card.
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Component, Element, State, h } from '@stencil/core';
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
import { hasSlot } from '../../utilities/slot';
|
import styles from 'sass:./card.scss';
|
||||||
|
import { hasSlot } from '../../internal/slot';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
|
@ -16,50 +17,42 @@ import { hasSlot } from '../../utilities/slot';
|
||||||
* @part body - The card's body.
|
* @part body - The card's body.
|
||||||
* @part footer - The card's footer, if present.
|
* @part footer - The card's footer, if present.
|
||||||
*/
|
*/
|
||||||
|
export default class SlCard extends Shoemaker {
|
||||||
|
static tag = 'sl-card';
|
||||||
|
static props = ['hasFooter', 'hasImage', 'hasHeader'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
@Component({
|
private hasFooter = false;
|
||||||
tag: 'sl-card',
|
private hasImage = false;
|
||||||
styleUrl: 'card.scss',
|
private hasHeader = false;
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class Card {
|
|
||||||
@Element() host: HTMLSlCardElement;
|
|
||||||
|
|
||||||
@State() hasFooter = false;
|
onConnect() {
|
||||||
@State() hasImage = false;
|
|
||||||
@State() hasHeader = false;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleSlotChange = this.handleSlotChange.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillLoad() {
|
|
||||||
this.handleSlotChange();
|
this.handleSlotChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSlotChange() {
|
handleSlotChange() {
|
||||||
this.hasFooter = hasSlot(this.host, 'footer');
|
this.hasFooter = hasSlot(this, 'footer');
|
||||||
this.hasImage = hasSlot(this.host, 'image');
|
this.hasImage = hasSlot(this, 'image');
|
||||||
this.hasHeader = hasSlot(this.host, 'header');
|
this.hasHeader = hasSlot(this, 'header');
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return html`
|
||||||
<div
|
<div
|
||||||
part="base"
|
part="base"
|
||||||
class={{
|
class=${classMap({
|
||||||
card: true,
|
card: true,
|
||||||
'card--has-footer': this.hasFooter,
|
'card--has-footer': this.hasFooter,
|
||||||
'card--has-image': this.hasImage,
|
'card--has-image': this.hasImage,
|
||||||
'card--has-header': this.hasHeader
|
'card--has-header': this.hasHeader
|
||||||
}}
|
})}
|
||||||
>
|
>
|
||||||
<div part="image" class="card__image">
|
<div part="image" class="card__image">
|
||||||
<slot name="image" onSlotchange={this.handleSlotChange} />
|
<slot name="image" onslotchange=${this.handleSlotChange.bind(this)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div part="header" class="card__header">
|
<div part="header" class="card__header">
|
||||||
<slot name="header" onSlotchange={this.handleSlotChange} />
|
<slot name="header" onslotchange=${this.handleSlotChange.bind(this)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div part="body" class="card__body">
|
<div part="body" class="card__body">
|
||||||
|
@ -67,9 +60,9 @@ export class Card {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div part="footer" class="card__footer">
|
<div part="footer" class="card__footer">
|
||||||
<slot name="footer" onSlotchange={this.handleSlotChange} />
|
<slot name="footer" onslotchange=${this.handleSlotChange.bind(this)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,138 +0,0 @@
|
||||||
import { newE2EPage } from '@stencil/core/testing';
|
|
||||||
|
|
||||||
describe('<sl-checkbox>', () => {
|
|
||||||
it('should emit sl-focus when gaining focus', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-checkbox>Checkbox</sl-checkbox>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const checkbox = await page.find('sl-checkbox');
|
|
||||||
const slFocus = await checkbox.spyOnEvent('sl-focus');
|
|
||||||
|
|
||||||
await checkbox.click();
|
|
||||||
|
|
||||||
expect(slFocus).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-blur when losing focus', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-checkbox>Checkbox</sl-checkbox>
|
|
||||||
<button>Native Button</button>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const checkbox = await page.find('sl-checkbox');
|
|
||||||
const nativeButton = await page.find('button');
|
|
||||||
const slBlur = await checkbox.spyOnEvent('sl-blur');
|
|
||||||
|
|
||||||
await checkbox.click();
|
|
||||||
await nativeButton.click();
|
|
||||||
|
|
||||||
expect(slBlur).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-focus when calling setFocus()', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-checkbox>Checkbox</sl-checkbox>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const checkbox = await page.find('sl-checkbox');
|
|
||||||
const slFocus = await checkbox.spyOnEvent('sl-focus');
|
|
||||||
|
|
||||||
await checkbox.callMethod('setFocus');
|
|
||||||
|
|
||||||
expect(slFocus).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-blur when calling removeFocus()', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-checkbox>Checkbox</sl-checkbox>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const checkbox = await page.find('sl-checkbox');
|
|
||||||
const slBlur = await checkbox.spyOnEvent('sl-blur');
|
|
||||||
|
|
||||||
await checkbox.callMethod('setFocus');
|
|
||||||
await checkbox.callMethod('removeFocus');
|
|
||||||
|
|
||||||
expect(slBlur).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-change when checked state changes via click', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-checkbox>Checkbox</sl-checkbox>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const checkbox = await page.find('sl-checkbox');
|
|
||||||
const slChange = await checkbox.spyOnEvent('sl-change');
|
|
||||||
|
|
||||||
await checkbox.click();
|
|
||||||
|
|
||||||
expect(slChange).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-change when setting checked attribute', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-checkbox>Checkbox</sl-checkbox>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const checkbox = await page.find('sl-checkbox');
|
|
||||||
const slChange = await checkbox.spyOnEvent('sl-change');
|
|
||||||
|
|
||||||
checkbox.setAttribute('checked', '');
|
|
||||||
await page.waitForChanges();
|
|
||||||
|
|
||||||
expect(slChange).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-change when removing checked attribute', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-checkbox checked>Checkbox</sl-checkbox>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const checkbox = await page.find('sl-checkbox');
|
|
||||||
const slChange = await checkbox.spyOnEvent('sl-change');
|
|
||||||
|
|
||||||
checkbox.removeAttribute('checked');
|
|
||||||
await page.waitForChanges();
|
|
||||||
|
|
||||||
expect(slChange).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-change when setting checked property to true', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-checkbox>Checkbox</sl-checkbox>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const checkbox = await page.find('sl-checkbox');
|
|
||||||
const slChange = await checkbox.spyOnEvent('sl-change');
|
|
||||||
|
|
||||||
checkbox.setProperty('checked', true);
|
|
||||||
await page.waitForChanges();
|
|
||||||
|
|
||||||
expect(slChange).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-change when setting checked property to false', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-checkbox checked>Checkbox</sl-checkbox>
|
|
||||||
<button>Native Button</button>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const checkbox = await page.find('sl-checkbox');
|
|
||||||
const slChange = await checkbox.spyOnEvent('sl-change');
|
|
||||||
|
|
||||||
checkbox.setProperty('checked', false);
|
|
||||||
await page.waitForChanges();
|
|
||||||
|
|
||||||
expect(slChange).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./checkbox.scss';
|
||||||
|
|
||||||
|
let id = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @since 2.0
|
||||||
|
* @status stable
|
||||||
|
*
|
||||||
|
* @slot - The checkbox's label.
|
||||||
|
*
|
||||||
|
* @part base - The component's base wrapper.
|
||||||
|
* @part control - The checkbox control.
|
||||||
|
* @part checked-icon - The container the wraps the checked icon.
|
||||||
|
* @part indeterminate-icon - The container that wraps the indeterminate icon.
|
||||||
|
* @part label - The checkbox label.
|
||||||
|
*
|
||||||
|
* @emit sl-blur - Emitted when the control loses focus.
|
||||||
|
* @emit sl-change - Emitted when the control's checked state changes.
|
||||||
|
* @emit sl-focus - Emitted when the control gains focus.
|
||||||
|
*/
|
||||||
|
export default class SlCheckbox extends Shoemaker {
|
||||||
|
static tag = 'sl-checkbox';
|
||||||
|
static props = ['hasFocus', 'name', 'value', 'disabled', 'required', 'checked', 'indeterminate', 'invalid'];
|
||||||
|
static reflect = ['checked', 'indeterminate', 'invalid'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
private inputId = `checkbox-${++id}`;
|
||||||
|
private labelId = `checkbox-label-${id}`;
|
||||||
|
private hasFocus = false;
|
||||||
|
private input: HTMLInputElement;
|
||||||
|
|
||||||
|
/** The checkbox's name attribute. */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** The checkbox's value attribute. */
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
/** Disables the checkbox. */
|
||||||
|
disabled = false;
|
||||||
|
|
||||||
|
/** Makes the checkbox a required field. */
|
||||||
|
required = false;
|
||||||
|
|
||||||
|
/** Draws the checkbox in a checked state. */
|
||||||
|
checked = false;
|
||||||
|
|
||||||
|
/** Draws the checkbox in an indeterminate state. */
|
||||||
|
indeterminate = false;
|
||||||
|
|
||||||
|
/** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */
|
||||||
|
invalid = false;
|
||||||
|
|
||||||
|
onReady() {
|
||||||
|
this.input.indeterminate = this.indeterminate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets focus on the checkbox. */
|
||||||
|
setFocus(options?: FocusOptions) {
|
||||||
|
this.input.focus(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes focus from the checkbox. */
|
||||||
|
removeFocus() {
|
||||||
|
this.input.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. If `message` is not empty, the field will be considered invalid. */
|
||||||
|
setCustomValidity(message: string) {
|
||||||
|
this.input.setCustomValidity(message);
|
||||||
|
this.invalid = !this.input.checkValidity();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick() {
|
||||||
|
this.checked = this.input.checked;
|
||||||
|
this.indeterminate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlur() {
|
||||||
|
this.hasFocus = false;
|
||||||
|
this.emit('sl-blur');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFocus() {
|
||||||
|
this.hasFocus = true;
|
||||||
|
this.emit('sl-focus');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLabelMouseDown(event: MouseEvent) {
|
||||||
|
// Prevent clicks on the label from briefly blurring the input
|
||||||
|
event.preventDefault();
|
||||||
|
this.input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStateChange() {
|
||||||
|
this.input.checked = this.checked;
|
||||||
|
this.input.indeterminate = this.indeterminate;
|
||||||
|
this.emit('sl-change');
|
||||||
|
}
|
||||||
|
|
||||||
|
watchChecked() {
|
||||||
|
this.handleStateChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchIndeterminate() {
|
||||||
|
this.handleStateChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
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
|
||||||
|
})}
|
||||||
|
for=${this.inputId}
|
||||||
|
onmousedown=${this.handleLabelMouseDown.bind(this)}
|
||||||
|
>
|
||||||
|
<span part="control" class="checkbox__control">
|
||||||
|
${this.checked
|
||||||
|
? html`
|
||||||
|
<span part="checked-icon" class="checkbox__icon">
|
||||||
|
<svg viewBox="0 0 16 16">
|
||||||
|
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
|
||||||
|
<g stroke="currentColor" stroke-width="2">
|
||||||
|
<g transform="translate(3.428571, 3.428571)">
|
||||||
|
<path d="M0,5.71428571 L3.42857143,9.14285714"></path>
|
||||||
|
<path d="M9.14285714,0 L3.42857143,9.14285714"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
${!this.checked && this.indeterminate
|
||||||
|
? html`
|
||||||
|
<span part="indeterminate-icon" class="checkbox__icon">
|
||||||
|
<svg viewBox="0 0 16 16">
|
||||||
|
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
|
||||||
|
<g stroke="currentColor" stroke-width="2">
|
||||||
|
<g transform="translate(2.285714, 6.857143)">
|
||||||
|
<path d="M10.2857143,1.14285714 L1.14285714,1.14285714"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref=${(el: HTMLInputElement) => (this.input = el)}
|
||||||
|
id=${this.inputId}
|
||||||
|
type="checkbox"
|
||||||
|
name=${this.name}
|
||||||
|
.value=${this.value}
|
||||||
|
checked=${this.checked ? true : null}
|
||||||
|
disabled=${this.disabled ? true : null}
|
||||||
|
required=${this.required ? true : null}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked=${this.checked ? 'true' : 'false'}
|
||||||
|
aria-labelledby=${this.labelId}
|
||||||
|
onclick=${this.handleClick.bind(this)}
|
||||||
|
onblur=${this.handleBlur.bind(this)}
|
||||||
|
onfocus=${this.handleFocus.bind(this)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span part="label" id=${this.labelId} class="checkbox__label">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,193 +0,0 @@
|
||||||
import { Component, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
|
|
||||||
|
|
||||||
let id = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @since 2.0
|
|
||||||
* @status stable
|
|
||||||
*
|
|
||||||
* @slot - The checkbox's label.
|
|
||||||
*
|
|
||||||
* @part base - The component's base wrapper.
|
|
||||||
* @part control - The checkbox control.
|
|
||||||
* @part checked-icon - The container the wraps the checked icon.
|
|
||||||
* @part indeterminate-icon - The container that wraps the indeterminate icon.
|
|
||||||
* @part label - The checkbox label.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'sl-checkbox',
|
|
||||||
styleUrl: 'checkbox.scss',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class Checkbox {
|
|
||||||
inputId = `checkbox-${++id}`;
|
|
||||||
labelId = `checkbox-label-${id}`;
|
|
||||||
input: HTMLInputElement;
|
|
||||||
|
|
||||||
@State() hasFocus = false;
|
|
||||||
|
|
||||||
/** The checkbox's name attribute. */
|
|
||||||
@Prop() name: string;
|
|
||||||
|
|
||||||
/** The checkbox's value attribute. */
|
|
||||||
@Prop() value: string;
|
|
||||||
|
|
||||||
/** Set to true to disable the checkbox. */
|
|
||||||
@Prop() disabled = false;
|
|
||||||
|
|
||||||
/** Set to true to make the checkbox a required field. */
|
|
||||||
@Prop() required = false;
|
|
||||||
|
|
||||||
/** Set to true to draw the checkbox in a checked state. */
|
|
||||||
@Prop({ mutable: true, reflect: true }) checked = false;
|
|
||||||
|
|
||||||
/** Set to true to draw the checkbox in an indeterminate state. */
|
|
||||||
@Prop({ mutable: true, reflect: true }) indeterminate = false;
|
|
||||||
|
|
||||||
/** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */
|
|
||||||
@Prop({ mutable: true, reflect: true }) invalid = false;
|
|
||||||
|
|
||||||
/** Emitted when the control loses focus. */
|
|
||||||
@Event({ eventName: 'sl-blur' }) slBlur: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the control's checked state changes. */
|
|
||||||
@Event({ eventName: 'sl-change' }) slChange: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the control gains focus. */
|
|
||||||
@Event({ eventName: 'sl-focus' }) slFocus: EventEmitter;
|
|
||||||
|
|
||||||
@Watch('checked')
|
|
||||||
@Watch('indeterminate')
|
|
||||||
handleCheckedChange() {
|
|
||||||
this.input.checked = this.checked;
|
|
||||||
this.input.indeterminate = this.indeterminate;
|
|
||||||
this.slChange.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleClick = this.handleClick.bind(this);
|
|
||||||
this.handleBlur = this.handleBlur.bind(this);
|
|
||||||
this.handleFocus = this.handleFocus.bind(this);
|
|
||||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidLoad() {
|
|
||||||
this.input.indeterminate = this.indeterminate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets focus on the checkbox. */
|
|
||||||
@Method()
|
|
||||||
async setFocus(options?: FocusOptions) {
|
|
||||||
this.input.focus(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Removes focus from the checkbox. */
|
|
||||||
@Method()
|
|
||||||
async removeFocus() {
|
|
||||||
this.input.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
|
||||||
@Method()
|
|
||||||
async reportValidity() {
|
|
||||||
return this.input.reportValidity();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
|
|
||||||
@Method()
|
|
||||||
async setCustomValidity(message: string) {
|
|
||||||
this.input.setCustomValidity(message);
|
|
||||||
this.invalid = !this.input.checkValidity();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick() {
|
|
||||||
this.checked = this.input.checked;
|
|
||||||
this.indeterminate = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBlur() {
|
|
||||||
this.hasFocus = false;
|
|
||||||
this.slBlur.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFocus() {
|
|
||||||
this.hasFocus = true;
|
|
||||||
this.slFocus.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMouseDown(event: MouseEvent) {
|
|
||||||
// Prevent clicks on the label from briefly blurring the input
|
|
||||||
event.preventDefault();
|
|
||||||
this.input.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
part="base"
|
|
||||||
class={{
|
|
||||||
checkbox: true,
|
|
||||||
'checkbox--checked': this.checked,
|
|
||||||
'checkbox--disabled': this.disabled,
|
|
||||||
'checkbox--focused': this.hasFocus,
|
|
||||||
'checkbox--indeterminate': this.indeterminate
|
|
||||||
}}
|
|
||||||
htmlFor={this.inputId}
|
|
||||||
onMouseDown={this.handleMouseDown}
|
|
||||||
>
|
|
||||||
<span part="control" class="checkbox__control">
|
|
||||||
{this.checked && (
|
|
||||||
<span part="checked-icon" class="checkbox__icon">
|
|
||||||
<svg viewBox="0 0 16 16">
|
|
||||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
|
|
||||||
<g stroke="currentColor" stroke-width="2">
|
|
||||||
<g transform="translate(3.428571, 3.428571)">
|
|
||||||
<path d="M0,5.71428571 L3.42857143,9.14285714"></path>
|
|
||||||
<path d="M9.14285714,0 L3.42857143,9.14285714"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!this.checked && this.indeterminate && (
|
|
||||||
<span part="indeterminate-icon" class="checkbox__icon">
|
|
||||||
<svg viewBox="0 0 16 16">
|
|
||||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
|
|
||||||
<g stroke="currentColor" stroke-width="2">
|
|
||||||
<g transform="translate(2.285714, 6.857143)">
|
|
||||||
<path d="M10.2857143,1.14285714 L1.14285714,1.14285714"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={el => (this.input = el)}
|
|
||||||
id={this.inputId}
|
|
||||||
type="checkbox"
|
|
||||||
name={this.name}
|
|
||||||
value={this.value}
|
|
||||||
checked={this.checked}
|
|
||||||
disabled={this.disabled}
|
|
||||||
required={this.required}
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={this.checked ? 'true' : 'false'}
|
|
||||||
aria-labelledby={this.labelId}
|
|
||||||
onClick={this.handleClick}
|
|
||||||
onBlur={this.handleBlur}
|
|
||||||
onFocus={this.handleFocus}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span part="label" id={this.labelId} class="checkbox__label">
|
|
||||||
<slot />
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,73 +0,0 @@
|
||||||
import { newE2EPage } from '@stencil/core/testing';
|
|
||||||
|
|
||||||
describe('<sl-color-picker>', () => {
|
|
||||||
it('should emit sl-show and sl-after-show events when opened', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-color-picker></sl-color-picker>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const colorPicker = await page.find('sl-color-picker');
|
|
||||||
const slShow = await colorPicker.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await colorPicker.spyOnEvent('sl-after-show');
|
|
||||||
const eventHappened = colorPicker.waitForEvent('sl-after-show');
|
|
||||||
|
|
||||||
await colorPicker.click();
|
|
||||||
await eventHappened;
|
|
||||||
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-hide and sl-after-hide events when closed', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-color-picker></sl-color-picker>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const colorPicker = await page.find('sl-color-picker');
|
|
||||||
const slHide = await colorPicker.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await colorPicker.spyOnEvent('sl-after-hide');
|
|
||||||
const eventHappened = colorPicker.waitForEvent('sl-after-hide');
|
|
||||||
|
|
||||||
await colorPicker.click(); // open
|
|
||||||
await colorPicker.click(); // close
|
|
||||||
await eventHappened;
|
|
||||||
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-change when value changes with click', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-color-picker></sl-color-picker>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const colorPicker = await page.find('sl-color-picker');
|
|
||||||
const colorPickerPicker = await page.find('sl-color-picker >>> .color-picker');
|
|
||||||
const slChange = await colorPicker.spyOnEvent('sl-change');
|
|
||||||
|
|
||||||
await colorPicker.click();
|
|
||||||
await colorPickerPicker.click();
|
|
||||||
|
|
||||||
expect(slChange).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should change value when clicking the color grid', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-color-picker></sl-color-picker>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const colorPicker = await page.find('sl-color-picker');
|
|
||||||
const colorPickerPicker = await page.find('sl-color-picker >>> .color-picker');
|
|
||||||
|
|
||||||
expect(await colorPicker.getProperty('value')).toBe('#ffffff');
|
|
||||||
|
|
||||||
await colorPicker.click();
|
|
||||||
await colorPickerPicker.click();
|
|
||||||
|
|
||||||
expect(await colorPicker.getProperty('value')).not.toBe('#ffffff');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @prop --grid-width: The width of the color grid.
|
* @prop --grid-width: The width of the color grid.
|
||||||
|
@ -142,7 +142,7 @@
|
||||||
margin-left: var(--sl-spacing-small);
|
margin-left: var(--sl-spacing-small);
|
||||||
cursor: copy;
|
cursor: copy;
|
||||||
|
|
||||||
&::before {
|
&:before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -175,7 +175,7 @@
|
||||||
width: calc(var(--sl-input-height-small) / 2);
|
width: calc(var(--sl-input-height-small) / 2);
|
||||||
height: calc(var(--sl-input-height-small) / 2);
|
height: calc(var(--sl-input-height-small) / 2);
|
||||||
color: white;
|
color: white;
|
||||||
background-color: rgba(0, 0, 0, 0.33);
|
background-color: var(--sl-color-gray-900);
|
||||||
border-radius: var(--sl-border-radius-circle);
|
border-radius: var(--sl-border-radius-circle);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
||||||
|
@ -186,22 +186,22 @@
|
||||||
|
|
||||||
@keyframes copied {
|
@keyframes copied {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(0.5);
|
transform: scale(0.8);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
25% {
|
30% {
|
||||||
transform: scale(1.2);
|
transform: scale(1.2);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
70% {
|
||||||
transform: scale(1);
|
transform: scale(1.2);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: scale(1.6);
|
transform: scale(1.4);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -313,7 +313,7 @@
|
||||||
border-radius: var(--sl-border-radius-circle);
|
border-radius: var(--sl-border-radius-circle);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&:before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -334,7 +334,7 @@
|
||||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-focus-ring-color-primary);
|
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-focus-ring-color-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
&::before {
|
&:before {
|
||||||
box-shadow: inset 0 0 0 1px var(--sl-color-primary-500);
|
box-shadow: inset 0 0 0 1px var(--sl-color-primary-500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
|
import { classMap, html, styleMap, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./color-picker.scss';
|
||||||
|
import { SlDropdown, SlInput } from '../../shoelace';
|
||||||
import color from 'color';
|
import color from 'color';
|
||||||
import { clamp } from '../../utilities/math';
|
import { clamp } from '../../internal/math';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
* @status stable
|
* @status stable
|
||||||
*
|
*
|
||||||
|
* @dependency sl-button
|
||||||
|
* @dependency sl-dropdown
|
||||||
|
* @dependency sl-icon
|
||||||
|
* @dependency sl-input
|
||||||
|
*
|
||||||
* @part base - The component's base wrapper.
|
* @part base - The component's base wrapper.
|
||||||
* @part trigger - The color picker's dropdown trigger.
|
* @part trigger - The color picker's dropdown trigger.
|
||||||
* @part swatches - The container that holds swatches.
|
* @part swatches - The container that holds swatches.
|
||||||
|
@ -19,79 +26,98 @@ import { clamp } from '../../utilities/math';
|
||||||
* @part preview - The preview color.
|
* @part preview - The preview color.
|
||||||
* @part input - The text input.
|
* @part input - The text input.
|
||||||
* @part format-button - The toggle format button's base.
|
* @part format-button - The toggle format button's base.
|
||||||
|
*
|
||||||
|
* @emit sl-change - Emitted when the color picker's value changes.
|
||||||
|
* @emit sl-show - Emitted when the color picker opens. Calling `event.preventDefault()` will prevent it from being opened.
|
||||||
|
* @emit sl-after - Emitted after the color picker opens and all transitions are complete.
|
||||||
|
* @emit sl-hide - Emitted when the color picker closes. Calling `event.preventDefault()` will prevent it from being closed.
|
||||||
|
* @emit sl-after - Emitted after the color picker closes and all transitions are complete.
|
||||||
*/
|
*/
|
||||||
|
export default class SlColorPicker extends Shoemaker {
|
||||||
|
static tag = 'sl-color-picker';
|
||||||
|
static props = [
|
||||||
|
'inputValue',
|
||||||
|
'hue',
|
||||||
|
'saturation',
|
||||||
|
'lightness',
|
||||||
|
'alpha',
|
||||||
|
'showCopyFeedback',
|
||||||
|
'value',
|
||||||
|
'format',
|
||||||
|
'inline',
|
||||||
|
'size',
|
||||||
|
'noFormatToggle',
|
||||||
|
'name',
|
||||||
|
'disabled',
|
||||||
|
'invalid',
|
||||||
|
'hoist',
|
||||||
|
'opacity',
|
||||||
|
'uppercase',
|
||||||
|
'swatches'
|
||||||
|
];
|
||||||
|
static reflect = ['disabled', 'invalid'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
@Component({
|
private alpha = 100;
|
||||||
tag: 'sl-color-picker',
|
private bypassValueParse = false;
|
||||||
styleUrl: 'color-picker.scss',
|
private dropdown: SlDropdown;
|
||||||
shadow: true
|
private hue = 0;
|
||||||
})
|
private input: SlInput;
|
||||||
export class ColorPicker {
|
private inputValue = '';
|
||||||
bypassValueParse = false;
|
private lastValueEmitted: string;
|
||||||
dropdown: HTMLSlDropdownElement;
|
private lightness = 100;
|
||||||
input: HTMLSlInputElement;
|
private previewButton: HTMLButtonElement;
|
||||||
lastValueEmitted: string;
|
private saturation = 100;
|
||||||
menu: HTMLElement;
|
private showCopyFeedback = false;
|
||||||
previewButton: HTMLButtonElement;
|
|
||||||
trigger: HTMLButtonElement;
|
|
||||||
|
|
||||||
@Element() host: HTMLSlColorPickerElement;
|
|
||||||
|
|
||||||
@State() inputValue = '';
|
|
||||||
@State() hue = 0;
|
|
||||||
@State() saturation = 100;
|
|
||||||
@State() lightness = 100;
|
|
||||||
@State() alpha = 100;
|
|
||||||
@State() showCopyFeedback = false;
|
|
||||||
|
|
||||||
/** The current color. */
|
/** The current color. */
|
||||||
@Prop({ mutable: true, reflect: true }) value = '#ffffff';
|
value = '#ffffff';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The format to use for the display value. If opacity is enabled, these will translate to HEXA, RGBA, and HSLA
|
* The format to use for the display value. If opacity is enabled, these will translate to HEXA, RGBA, and HSLA
|
||||||
* respectively. The color picker will always accept user input in any format (including CSS color names) and convert
|
* respectively. The color picker will always accept user input in any format (including CSS color names) and convert
|
||||||
* it to the desired format.
|
* it to the desired format.
|
||||||
*/
|
*/
|
||||||
@Prop({ mutable: true }) format: 'hex' | 'rgb' | 'hsl' = 'hex';
|
format: 'hex' | 'rgb' | 'hsl' = 'hex';
|
||||||
|
|
||||||
/** Set to true to render the color picker inline rather than inside a dropdown. */
|
/** Renders the color picker inline rather than inside a dropdown. */
|
||||||
@Prop() inline = false;
|
inline = false;
|
||||||
|
|
||||||
/** Determines the size of the color picker's trigger. This has no effect on inline color pickers. */
|
/** Determines the size of the color picker's trigger. This has no effect on inline color pickers. */
|
||||||
@Prop() size: 'small' | 'medium' | 'large' = 'medium';
|
size: 'small' | 'medium' | 'large' = 'medium';
|
||||||
|
|
||||||
/** Removes the format toggle. */
|
/** Removes the format toggle. */
|
||||||
@Prop() noFormatToggle = false;
|
noFormatToggle = false;
|
||||||
|
|
||||||
/** The input's name attribute. */
|
/** The input's name attribute. */
|
||||||
@Prop({ reflect: true }) name = '';
|
name = '';
|
||||||
|
|
||||||
/** Set to true to disable the color picker. */
|
/** Disables the color picker. */
|
||||||
@Prop() disabled = false;
|
disabled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This will be true when the control is in an invalid state. Validity is determined by the `setCustomValidity()`
|
* This will be true when the control is in an invalid state. Validity is determined by the `setCustomValidity()`
|
||||||
* method using the browser's constraint validation API.
|
* method using the browser's constraint validation API.
|
||||||
*/
|
*/
|
||||||
@Prop({ mutable: true, reflect: true }) invalid = false;
|
invalid = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
|
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
|
||||||
* `overflow: auto|scroll`.
|
* `overflow: auto|scroll`.
|
||||||
*/
|
*/
|
||||||
@Prop() hoist = false;
|
hoist = false;
|
||||||
|
|
||||||
/** Whether to show the opacity slider. */
|
/** Whether to show the opacity slider. */
|
||||||
@Prop() opacity = false;
|
opacity = false;
|
||||||
|
|
||||||
/** By default, the value will be set in lowercase. Set this to true to set it in uppercase instead. */
|
/** By default, the value will be set in lowercase. Set this to true to set it in uppercase instead. */
|
||||||
@Prop() uppercase = false;
|
uppercase = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An array of predefined color swatches to display. Can include any format the color picker can parse, including
|
* An array of predefined color swatches to display. Can include any format the color picker can parse, including
|
||||||
* HEX(A), RGB(A), HSL(A), and CSS color names.
|
* HEX(A), RGB(A), HSL(A), and CSS color names.
|
||||||
*/
|
*/
|
||||||
@Prop() swatches = [
|
swatches = [
|
||||||
'#d0021b',
|
'#d0021b',
|
||||||
'#f5a623',
|
'#f5a623',
|
||||||
'#f8e71c',
|
'#f8e71c',
|
||||||
|
@ -110,77 +136,7 @@ export class ColorPicker {
|
||||||
'#fff'
|
'#fff'
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Emitted when the color picker's value changes. */
|
onReady() {
|
||||||
@Event({ eventName: 'sl-change' }) slChange: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the color picker opens. Calling `event.preventDefault()` will prevent it from being opened. */
|
|
||||||
@Event({ eventName: 'sl-show' }) slShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the color picker opens and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-show' }) slAfterShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the color picker closes. Calling `event.preventDefault()` will prevent it from being closed. */
|
|
||||||
@Event({ eventName: 'sl-hide' }) slHide: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the color picker closes and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-hide' }) slAfterHide: EventEmitter;
|
|
||||||
|
|
||||||
@Watch('format')
|
|
||||||
handleFormatChange() {
|
|
||||||
this.syncValues();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch('opacity')
|
|
||||||
handleOpacityChange() {
|
|
||||||
this.alpha = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch('value')
|
|
||||||
handleValueChange(newValue: string, oldValue: string) {
|
|
||||||
if (!this.bypassValueParse) {
|
|
||||||
const newColor = this.parseColor(newValue);
|
|
||||||
|
|
||||||
if (newColor) {
|
|
||||||
this.inputValue = this.value;
|
|
||||||
this.hue = newColor.hsla.h;
|
|
||||||
this.saturation = newColor.hsla.s;
|
|
||||||
this.lightness = newColor.hsla.l;
|
|
||||||
this.alpha = newColor.hsla.a * 100;
|
|
||||||
} else {
|
|
||||||
this.inputValue = oldValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.value !== this.lastValueEmitted) {
|
|
||||||
this.slChange.emit();
|
|
||||||
this.lastValueEmitted = this.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleAlphaDrag = this.handleAlphaDrag.bind(this);
|
|
||||||
this.handleAlphaInput = this.handleAlphaInput.bind(this);
|
|
||||||
this.handleAlphaKeyDown = this.handleAlphaKeyDown.bind(this);
|
|
||||||
this.handleCopy = this.handleCopy.bind(this);
|
|
||||||
this.handleFormatToggle = this.handleFormatToggle.bind(this);
|
|
||||||
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
|
|
||||||
this.handleDrag = this.handleDrag.bind(this);
|
|
||||||
this.handleDropdownAfterHide = this.handleDropdownAfterHide.bind(this);
|
|
||||||
this.handleDropdownAfterShow = this.handleDropdownAfterShow.bind(this);
|
|
||||||
this.handleDropdownHide = this.handleDropdownHide.bind(this);
|
|
||||||
this.handleDropdownShow = this.handleDropdownShow.bind(this);
|
|
||||||
this.handleGridDrag = this.handleGridDrag.bind(this);
|
|
||||||
this.handleGridKeyDown = this.handleGridKeyDown.bind(this);
|
|
||||||
this.handleHueDrag = this.handleHueDrag.bind(this);
|
|
||||||
this.handleHueInput = this.handleHueInput.bind(this);
|
|
||||||
this.handleHueKeyDown = this.handleHueKeyDown.bind(this);
|
|
||||||
this.handleLightnessInput = this.handleLightnessInput.bind(this);
|
|
||||||
this.handleSaturationInput = this.handleSaturationInput.bind(this);
|
|
||||||
this.handleInputChange = this.handleInputChange.bind(this);
|
|
||||||
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillLoad() {
|
|
||||||
if (!this.setColor(this.value)) {
|
if (!this.setColor(this.value)) {
|
||||||
this.setColor(`#ffff`);
|
this.setColor(`#ffff`);
|
||||||
}
|
}
|
||||||
|
@ -191,8 +147,7 @@ export class ColorPicker {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the current value as a string in the specified format. */
|
/** Returns the current value as a string in the specified format. */
|
||||||
@Method()
|
getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' = 'hex') {
|
||||||
async getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' = 'hex') {
|
|
||||||
const currentColor = this.parseColor(
|
const currentColor = this.parseColor(
|
||||||
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
|
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
|
||||||
);
|
);
|
||||||
|
@ -220,8 +175,7 @@ export class ColorPicker {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||||
@Method()
|
reportValidity() {
|
||||||
async reportValidity() {
|
|
||||||
// If the input is invalid, show the dropdown so the browser can focus on it
|
// If the input is invalid, show the dropdown so the browser can focus on it
|
||||||
if (!this.inline && this.input.invalid) {
|
if (!this.inline && this.input.invalid) {
|
||||||
return new Promise<void>(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
|
@ -241,19 +195,17 @@ export class ColorPicker {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
|
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
|
||||||
@Method()
|
setCustomValidity(message: string) {
|
||||||
async setCustomValidity(message: string) {
|
this.input.setCustomValidity(message);
|
||||||
await this.input.setCustomValidity(message);
|
|
||||||
this.invalid = this.input.invalid;
|
this.invalid = this.input.invalid;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCopy() {
|
handleCopy() {
|
||||||
this.input.select().then(() => {
|
this.input.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
this.previewButton.focus();
|
this.previewButton.focus();
|
||||||
this.showCopyFeedback = true;
|
this.showCopyFeedback = true;
|
||||||
this.previewButton.addEventListener('animationend', () => (this.showCopyFeedback = false), { once: true });
|
this.previewButton.addEventListener('animationend', () => (this.showCopyFeedback = false), { once: true });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFormatToggle() {
|
handleFormatToggle() {
|
||||||
|
@ -262,28 +214,8 @@ export class ColorPicker {
|
||||||
this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl';
|
this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl';
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHueInput(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
this.hue = clamp(Number(target.value), 0, 360);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSaturationInput(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
this.saturation = clamp(Number(target.value), 0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLightnessInput(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
this.lightness = clamp(Number(target.value), 0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAlphaInput(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
this.alpha = clamp(Number(target.value), 0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAlphaDrag(event: any) {
|
handleAlphaDrag(event: any) {
|
||||||
const container = this.host.shadowRoot.querySelector('.color-picker__slider.color-picker__alpha') as HTMLElement;
|
const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__alpha') as HTMLElement;
|
||||||
const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement;
|
const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement;
|
||||||
const { width } = container.getBoundingClientRect();
|
const { width } = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
@ -297,7 +229,7 @@ export class ColorPicker {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHueDrag(event: any) {
|
handleHueDrag(event: any) {
|
||||||
const container = this.host.shadowRoot.querySelector('.color-picker__slider.color-picker__hue') as HTMLElement;
|
const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__hue') as HTMLElement;
|
||||||
const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement;
|
const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement;
|
||||||
const { width } = container.getBoundingClientRect();
|
const { width } = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
@ -311,7 +243,7 @@ export class ColorPicker {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGridDrag(event: any) {
|
handleGridDrag(event: any) {
|
||||||
const grid = this.host.shadowRoot.querySelector('.color-picker__grid') as HTMLElement;
|
const grid = this.shadowRoot!.querySelector('.color-picker__grid') as HTMLElement;
|
||||||
const handle = grid.querySelector('.color-picker__grid-handle') as HTMLElement;
|
const handle = grid.querySelector('.color-picker__grid-handle') as HTMLElement;
|
||||||
const { width, height } = grid.getBoundingClientRect();
|
const { width, height } = grid.getBoundingClientRect();
|
||||||
|
|
||||||
|
@ -327,13 +259,14 @@ export class ColorPicker {
|
||||||
|
|
||||||
handleDrag(event: any, container: HTMLElement, onMove: (x: number, y: number) => void) {
|
handleDrag(event: any, container: HTMLElement, onMove: (x: number, y: number) => void) {
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const move = (event: any) => {
|
const move = (event: any) => {
|
||||||
const dims = container.getBoundingClientRect();
|
const dims = container.getBoundingClientRect();
|
||||||
const offsetX = dims.left + container.ownerDocument.defaultView.pageXOffset;
|
const defaultView = container.ownerDocument.defaultView!;
|
||||||
const offsetY = dims.top + container.ownerDocument.defaultView.pageYOffset;
|
const offsetX = dims.left + defaultView.pageXOffset;
|
||||||
|
const offsetY = dims.top + defaultView.pageYOffset;
|
||||||
const x = (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - offsetX;
|
const x = (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - offsetX;
|
||||||
const y = (event.changedTouches ? event.changedTouches[0].pageY : event.pageY) - offsetY;
|
const y = (event.changedTouches ? event.changedTouches[0].pageY : event.pageY) - offsetY;
|
||||||
|
|
||||||
|
@ -456,33 +389,24 @@ export class ColorPicker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDocumentMouseDown(event: MouseEvent) {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
|
|
||||||
// Close when clicking outside of the dropdown
|
|
||||||
if (target.closest('sl-color-picker') !== this.host) {
|
|
||||||
this.dropdown.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDropdownShow(event: CustomEvent) {
|
handleDropdownShow(event: CustomEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.slShow.emit();
|
this.emit('sl-show');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDropdownAfterShow(event: CustomEvent) {
|
handleDropdownAfterShow(event: CustomEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.slAfterShow.emit();
|
this.emit('sl-after-show');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDropdownHide(event: CustomEvent) {
|
handleDropdownHide(event: CustomEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.slHide.emit();
|
this.emit('sl-hide');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDropdownAfterHide(event: CustomEvent) {
|
handleDropdownAfterHide(event: CustomEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.slAfterHide.emit();
|
this.emit('sl-after-hide');
|
||||||
this.showCopyFeedback = false;
|
this.showCopyFeedback = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -641,7 +565,7 @@ export class ColorPicker {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentColor) {
|
if (!currentColor) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the value
|
// Update the value
|
||||||
|
@ -660,212 +584,245 @@ export class ColorPicker {
|
||||||
this.bypassValueParse = false;
|
this.bypassValueParse = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watchFormat() {
|
||||||
|
this.syncValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchOpacity() {
|
||||||
|
this.alpha = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
watchValue(newValue: string, oldValue: string) {
|
||||||
|
if (!this.bypassValueParse) {
|
||||||
|
const newColor = this.parseColor(newValue);
|
||||||
|
|
||||||
|
if (newColor) {
|
||||||
|
this.inputValue = this.value;
|
||||||
|
this.hue = newColor.hsla.h;
|
||||||
|
this.saturation = newColor.hsla.s;
|
||||||
|
this.lightness = newColor.hsla.l;
|
||||||
|
this.alpha = newColor.hsla.a * 100;
|
||||||
|
} else {
|
||||||
|
this.inputValue = oldValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.value !== this.lastValueEmitted) {
|
||||||
|
this.emit('sl-change');
|
||||||
|
this.lastValueEmitted = this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const x = this.saturation;
|
const x = this.saturation;
|
||||||
const y = 100 - this.lightness;
|
const y = 100 - this.lightness;
|
||||||
|
|
||||||
const ColorPicker = () => {
|
const colorPicker = html`
|
||||||
return (
|
<div
|
||||||
|
part="base"
|
||||||
|
class=${classMap({
|
||||||
|
'color-picker': true,
|
||||||
|
'color-picker--inline': this.inline,
|
||||||
|
'color-picker--disabled': this.disabled
|
||||||
|
})}
|
||||||
|
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
part="base"
|
part="grid"
|
||||||
class={{
|
class="color-picker__grid"
|
||||||
'color-picker': true,
|
style=${styleMap({ backgroundColor: `hsl(${this.hue}deg, 100%, 50%)` })}
|
||||||
'color-picker--inline': this.inline,
|
onmousedown=${this.handleGridDrag.bind(this)}
|
||||||
'color-picker--disabled': this.disabled
|
ontouchstart=${this.handleGridDrag.bind(this)}
|
||||||
}}
|
|
||||||
aria-disabled={this.disabled ? 'true' : 'false'}
|
|
||||||
>
|
>
|
||||||
<div
|
<span
|
||||||
part="grid"
|
part="grid-handle"
|
||||||
class="color-picker__grid"
|
class="color-picker__grid-handle"
|
||||||
style={{
|
style=${styleMap({
|
||||||
backgroundColor: `hsl(${this.hue}deg, 100%, 50%)`
|
top: `${y}%`,
|
||||||
}}
|
left: `${x}%`,
|
||||||
onMouseDown={this.handleGridDrag}
|
backgroundColor: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%)`
|
||||||
onTouchStart={this.handleGridDrag}
|
})}
|
||||||
>
|
role="slider"
|
||||||
<span
|
aria-label="HSL"
|
||||||
part="grid-handle"
|
aria-valuetext=${`hsl(${Math.round(this.hue)}, ${Math.round(this.saturation)}%, ${Math.round(
|
||||||
class="color-picker__grid-handle"
|
this.lightness
|
||||||
style={{
|
)}%)`}
|
||||||
top: `${y}%`,
|
tabindex=${this.disabled ? null : '0'}
|
||||||
left: `${x}%`,
|
onkeydown=${this.handleGridKeyDown.bind(this)}
|
||||||
backgroundColor: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%)`
|
/>
|
||||||
}}
|
|
||||||
role="slider"
|
|
||||||
aria-label="HSL"
|
|
||||||
aria-valuetext={`hsl(${Math.round(this.hue)}, ${Math.round(this.saturation)}%, ${Math.round(
|
|
||||||
this.lightness
|
|
||||||
)}%)`}
|
|
||||||
tabIndex={this.disabled ? null : 0}
|
|
||||||
onKeyDown={this.handleGridKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="color-picker__controls">
|
|
||||||
<div class="color-picker__sliders">
|
|
||||||
<div
|
|
||||||
part="slider hue-slider"
|
|
||||||
class="color-picker__hue color-picker__slider"
|
|
||||||
onMouseDown={this.handleHueDrag}
|
|
||||||
onTouchStart={this.handleHueDrag}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
part="slider-handle"
|
|
||||||
class="color-picker__slider-handle"
|
|
||||||
style={{
|
|
||||||
left: `${this.hue === 0 ? 0 : 100 / (360 / this.hue)}%`
|
|
||||||
}}
|
|
||||||
role="slider"
|
|
||||||
aria-label="hue"
|
|
||||||
aria-orientation="horizontal"
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="360"
|
|
||||||
aria-valuenow={Math.round(this.hue)}
|
|
||||||
tabIndex={this.disabled ? null : 0}
|
|
||||||
onKeyDown={this.handleHueKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.opacity && (
|
|
||||||
<div
|
|
||||||
part="slider opacity-slider"
|
|
||||||
class="color-picker__alpha color-picker__slider color-picker__transparent-bg"
|
|
||||||
onMouseDown={this.handleAlphaDrag}
|
|
||||||
onTouchStart={this.handleAlphaDrag}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="color-picker__alpha-gradient"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `linear-gradient(
|
|
||||||
to right,
|
|
||||||
hsl(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, 0%) 0%,
|
|
||||||
hsl(${this.hue}deg, ${this.saturation}%, ${this.lightness}%) 100%
|
|
||||||
)`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
part="slider-handle"
|
|
||||||
class="color-picker__slider-handle"
|
|
||||||
style={{
|
|
||||||
left: `${this.alpha}%`
|
|
||||||
}}
|
|
||||||
role="slider"
|
|
||||||
aria-label="alpha"
|
|
||||||
aria-orientation="horizontal"
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="100"
|
|
||||||
aria-valuenow={Math.round(this.alpha)}
|
|
||||||
tabIndex={this.disabled ? null : 0}
|
|
||||||
onKeyDown={this.handleAlphaKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
ref={el => (this.previewButton = el)}
|
|
||||||
type="button"
|
|
||||||
part="preview"
|
|
||||||
class="color-picker__preview color-picker__transparent-bg"
|
|
||||||
style={{
|
|
||||||
'--preview-color': `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
|
|
||||||
}}
|
|
||||||
onClick={this.handleCopy}
|
|
||||||
>
|
|
||||||
<sl-icon
|
|
||||||
name="check"
|
|
||||||
class={{
|
|
||||||
'color-picker__copy-feedback': true,
|
|
||||||
'color-picker__copy-feedback--visible': this.showCopyFeedback,
|
|
||||||
'color-picker__copy-feedback--dark': this.lightness > 50
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="color-picker__user-input">
|
|
||||||
<sl-input
|
|
||||||
ref={el => (this.input = el)}
|
|
||||||
part="input"
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
name={this.name}
|
|
||||||
autocomplete="off"
|
|
||||||
autocorrect="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
spellcheck={false}
|
|
||||||
value={this.inputValue}
|
|
||||||
disabled={this.disabled}
|
|
||||||
onKeyDown={this.handleInputKeyDown}
|
|
||||||
onSl-change={this.handleInputChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!this.noFormatToggle && (
|
|
||||||
<sl-button exportparts="base:format-button" size="small" onClick={this.handleFormatToggle}>
|
|
||||||
{this.setLetterCase(this.format)}
|
|
||||||
</sl-button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.swatches && (
|
|
||||||
<div part="swatches" class="color-picker__swatches">
|
|
||||||
{this.swatches.map(swatch => (
|
|
||||||
<div
|
|
||||||
part="swatch"
|
|
||||||
class="color-picker__swatch color-picker__transparent-bg"
|
|
||||||
tabIndex={this.disabled ? null : 0}
|
|
||||||
role="button"
|
|
||||||
aria-label={swatch}
|
|
||||||
onClick={() => !this.disabled && this.setColor(swatch)}
|
|
||||||
onKeyDown={event => !this.disabled && event.key === 'Enter' && this.setColor(swatch)}
|
|
||||||
>
|
|
||||||
<div class="color-picker__swatch-color" style={{ backgroundColor: swatch }} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
};
|
<div class="color-picker__controls">
|
||||||
|
<div class="color-picker__sliders">
|
||||||
|
<div
|
||||||
|
part="slider hue-slider"
|
||||||
|
class="color-picker__hue color-picker__slider"
|
||||||
|
onmousedown=${this.handleHueDrag.bind(this)}
|
||||||
|
ontouchstart=${this.handleHueDrag.bind(this)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
part="slider-handle"
|
||||||
|
class="color-picker__slider-handle"
|
||||||
|
style=${styleMap({
|
||||||
|
left: `${this.hue === 0 ? 0 : 100 / (360 / this.hue)}%`
|
||||||
|
})}
|
||||||
|
role="slider"
|
||||||
|
aria-label="hue"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="360"
|
||||||
|
aria-valuenow=${Math.round(this.hue)}
|
||||||
|
tabindex=${this.disabled ? null : 0}
|
||||||
|
onkeydown=${this.handleHueKeyDown.bind(this)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.opacity
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
part="slider opacity-slider"
|
||||||
|
class="color-picker__alpha color-picker__slider color-picker__transparent-bg"
|
||||||
|
onmousedown="${this.handleAlphaDrag.bind(this)}"
|
||||||
|
ontouchstart="${this.handleAlphaDrag.bind(this)}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="color-picker__alpha-gradient"
|
||||||
|
style=${styleMap({
|
||||||
|
backgroundImage: `linear-gradient(
|
||||||
|
to right,
|
||||||
|
hsl(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, 0%) 0%,
|
||||||
|
hsl(${this.hue}deg, ${this.saturation}%, ${this.lightness}%) 100%
|
||||||
|
)`
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
part="slider-handle"
|
||||||
|
class="color-picker__slider-handle"
|
||||||
|
style=${styleMap({
|
||||||
|
left: `${this.alpha}%`
|
||||||
|
})}
|
||||||
|
role="slider"
|
||||||
|
aria-label="alpha"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow=${Math.round(this.alpha)}
|
||||||
|
tabindex=${this.disabled ? null : '0'}
|
||||||
|
onkeydown=${this.handleAlphaKeyDown.bind(this)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref=${(el: HTMLButtonElement) => (this.previewButton = el)}
|
||||||
|
type="button"
|
||||||
|
part="preview"
|
||||||
|
class="color-picker__preview color-picker__transparent-bg"
|
||||||
|
style=${styleMap({
|
||||||
|
'--preview-color': `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
|
||||||
|
})}
|
||||||
|
onclick=${this.handleCopy.bind(this)}
|
||||||
|
>
|
||||||
|
<sl-icon
|
||||||
|
name="check"
|
||||||
|
class=${classMap({
|
||||||
|
'color-picker__copy-feedback': true,
|
||||||
|
'color-picker__copy-feedback--visible': this.showCopyFeedback,
|
||||||
|
'color-picker__copy-feedback--dark': this.lightness > 50
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-picker__user-input">
|
||||||
|
<sl-input
|
||||||
|
ref=${(el: SlInput) => (this.input = el)}
|
||||||
|
part="input"
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
name=${this.name}
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
.value=${this.inputValue}
|
||||||
|
disabled=${this.disabled ? true : null}
|
||||||
|
onkeydown=${this.handleInputKeyDown.bind(this)}
|
||||||
|
onsl-change=${this.handleInputChange.bind(this)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
${!this.noFormatToggle
|
||||||
|
? html`
|
||||||
|
<sl-button exportparts="base:format-button" size="small" onclick=${this.handleFormatToggle.bind(this)}>
|
||||||
|
${this.setLetterCase(this.format)}
|
||||||
|
</sl-button>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.swatches
|
||||||
|
? html`
|
||||||
|
<div part="swatches" class="color-picker__swatches">
|
||||||
|
${this.swatches.map(swatch => {
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
part="swatch"
|
||||||
|
class="color-picker__swatch color-picker__transparent-bg"
|
||||||
|
tabindex=${this.disabled ? null : '0'}
|
||||||
|
role="button"
|
||||||
|
aria-label=${swatch}
|
||||||
|
onclick=${() => !this.disabled && this.setColor(swatch)}
|
||||||
|
onkeydown=${(event: KeyboardEvent) =>
|
||||||
|
!this.disabled && event.key === 'Enter' && this.setColor(swatch)}
|
||||||
|
>
|
||||||
|
<div class="color-picker__swatch-color" style=${styleMap({ backgroundColor: swatch })} />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
// Render inline
|
// Render inline
|
||||||
if (this.inline) {
|
if (this.inline) {
|
||||||
return <ColorPicker />;
|
return colorPicker;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render as a dropdown
|
// Render as a dropdown
|
||||||
return (
|
return html`
|
||||||
<sl-dropdown
|
<sl-dropdown
|
||||||
ref={el => (this.dropdown = el)}
|
ref=${(el: SlDropdown) => (this.dropdown = el)}
|
||||||
class="color-dropdown"
|
class="color-dropdown"
|
||||||
aria-disabled={this.disabled ? 'true' : 'false'}
|
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||||
containingElement={this.host}
|
.containing-element=${this}
|
||||||
hoist={this.hoist}
|
hoist=${this.hoist}
|
||||||
onSl-show={this.handleDropdownShow}
|
onsl-show=${this.handleDropdownShow.bind(this)}
|
||||||
onSl-after-show={this.handleDropdownAfterShow}
|
onsl-after-show=${this.handleDropdownAfterShow.bind(this)}
|
||||||
onSl-hide={this.handleDropdownHide}
|
onsl-hide=${this.handleDropdownHide.bind(this)}
|
||||||
onSl-after-hide={this.handleDropdownAfterHide}
|
onsl-after-hide=${this.handleDropdownAfterHide.bind(this)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
ref={el => (this.trigger = el)}
|
|
||||||
part="trigger"
|
part="trigger"
|
||||||
slot="trigger"
|
slot="trigger"
|
||||||
class={{
|
class=${classMap({
|
||||||
'color-dropdown__trigger': true,
|
'color-dropdown__trigger': true,
|
||||||
'color-dropdown__trigger--disabled': this.disabled,
|
'color-dropdown__trigger--disabled': this.disabled,
|
||||||
'color-dropdown__trigger--small': this.size === 'small',
|
'color-dropdown__trigger--small': this.size === 'small',
|
||||||
'color-dropdown__trigger--medium': this.size === 'medium',
|
'color-dropdown__trigger--medium': this.size === 'medium',
|
||||||
'color-dropdown__trigger--large': this.size === 'large',
|
'color-dropdown__trigger--large': this.size === 'large',
|
||||||
'color-picker__transparent-bg': true
|
'color-picker__transparent-bg': true
|
||||||
}}
|
})}
|
||||||
style={{
|
style=${styleMap({
|
||||||
color: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
|
color: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
|
||||||
}}
|
})}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
<ColorPicker />
|
${colorPicker}
|
||||||
</sl-dropdown>
|
</sl-dropdown>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,132 +0,0 @@
|
||||||
import { newE2EPage } from '@stencil/core/testing';
|
|
||||||
|
|
||||||
describe('<sl-details>', () => {
|
|
||||||
it('should open and close when summary is clicked', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-details summary="Toggle 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 details = await page.find('sl-details');
|
|
||||||
const header = await page.find('sl-details >>> header');
|
|
||||||
const base = await page.find('sl-details >>> .details__body');
|
|
||||||
|
|
||||||
let style = await base.getComputedStyle();
|
|
||||||
expect(style.height).toBe('0px');
|
|
||||||
|
|
||||||
const showEventHappened = details.waitForEvent('sl-after-show');
|
|
||||||
await header.click();
|
|
||||||
await showEventHappened;
|
|
||||||
style = await base.getComputedStyle();
|
|
||||||
expect(style.height).not.toBe('0px');
|
|
||||||
|
|
||||||
const hideEventHappened = details.waitForEvent('sl-after-hide');
|
|
||||||
await header.click();
|
|
||||||
await hideEventHappened;
|
|
||||||
style = await base.getComputedStyle();
|
|
||||||
expect(style.height).toBe('0px');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open and close with the show() and hide() methods', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-details summary="Toggle 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 details = await page.find('sl-details');
|
|
||||||
const base = await page.find('sl-details >>> .details__body');
|
|
||||||
|
|
||||||
let style = await base.getComputedStyle();
|
|
||||||
expect(style.height).toBe('0px');
|
|
||||||
|
|
||||||
const showEventHappened = details.waitForEvent('sl-after-show');
|
|
||||||
await details.callMethod('show');
|
|
||||||
await showEventHappened;
|
|
||||||
style = await base.getComputedStyle();
|
|
||||||
expect(style.height).not.toBe('0px');
|
|
||||||
|
|
||||||
const hideEventHappened = details.waitForEvent('sl-after-hide');
|
|
||||||
await details.callMethod('hide');
|
|
||||||
await hideEventHappened;
|
|
||||||
style = await base.getComputedStyle();
|
|
||||||
expect(style.height).toBe('0px');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open and close when the open attribute is added and removed', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-details summary="Toggle 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 details = await page.find('sl-details');
|
|
||||||
const base = await page.find('sl-details >>> .details__body');
|
|
||||||
|
|
||||||
let style = await base.getComputedStyle();
|
|
||||||
expect(style.height).toBe('0px');
|
|
||||||
|
|
||||||
const showEventHappened = details.waitForEvent('sl-after-show');
|
|
||||||
details.setAttribute('open', '');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await showEventHappened;
|
|
||||||
style = await base.getComputedStyle();
|
|
||||||
expect(style.height).not.toBe('0px');
|
|
||||||
|
|
||||||
const hideEventHappened = details.waitForEvent('sl-after-hide');
|
|
||||||
details.removeAttribute('open');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await hideEventHappened;
|
|
||||||
style = await base.getComputedStyle();
|
|
||||||
expect(style.height).toBe('0px');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-show and sl-after-show events when opened', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-details summary="Toggle 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 details = await page.find('sl-details');
|
|
||||||
const slShow = await details.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await details.spyOnEvent('sl-after-show');
|
|
||||||
const showEventHappened = details.waitForEvent('sl-after-show');
|
|
||||||
|
|
||||||
await details.callMethod('show');
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-hide and sl-after-hide events when closed', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-details summary="Toggle Me" open>
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
|
|
||||||
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
|
||||||
</sl-details>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const details = await page.find('sl-details');
|
|
||||||
const slHide = await details.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await details.spyOnEvent('sl-after-hide');
|
|
||||||
const hideEventHappened = details.waitForEvent('sl-after-hide');
|
|
||||||
|
|
||||||
await details.callMethod('hide');
|
|
||||||
await hideEventHappened;
|
|
||||||
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @prop --hide-duration: The length of the hide transition.
|
* @prop --hide-duration: The length of the hide transition.
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Component, Event, EventEmitter, Method, Prop, Watch, h } from '@stencil/core';
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
import { focusVisible } from '../../utilities/focus-visible';
|
import styles from 'sass:./details.scss';
|
||||||
|
import { focusVisible } from '../../internal/focus-visible';
|
||||||
|
|
||||||
let id = 0;
|
let id = 0;
|
||||||
|
|
||||||
|
@ -7,6 +8,8 @@ let id = 0;
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
* @status stable
|
* @status stable
|
||||||
*
|
*
|
||||||
|
* @dependency sl-icon
|
||||||
|
*
|
||||||
* @slot - The details' content.
|
* @slot - The details' content.
|
||||||
* @slot summary - The details' summary. Alternatively, you can use the summary prop.
|
* @slot summary - The details' summary. Alternatively, you can use the summary prop.
|
||||||
*
|
*
|
||||||
|
@ -15,53 +18,34 @@ let id = 0;
|
||||||
* @part summary - The details summary.
|
* @part summary - The details summary.
|
||||||
* @part summary-icon - The expand/collapse summary icon.
|
* @part summary-icon - The expand/collapse summary icon.
|
||||||
* @part content - The details content.
|
* @part content - The details content.
|
||||||
|
*
|
||||||
|
* @emit sl-show - Emitted when the details opens. Calling `event.preventDefault()` will prevent it from being opened.
|
||||||
|
* @emit after-show - Emitted after the details opens and all transitions are complete.
|
||||||
|
* @emit sl-hide - Emitted when the details closes. Calling `event.preventDefault()` will prevent it from being closed.
|
||||||
|
* @emit after-hide - Emitted after the details closes and all transitions are complete.
|
||||||
*/
|
*/
|
||||||
|
export default class SlDetails extends Shoemaker {
|
||||||
|
static tag = 'sl-details';
|
||||||
|
static props = ['open', 'summary', 'disabled'];
|
||||||
|
static reflect = ['open', 'disabled'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
@Component({
|
private body: HTMLElement;
|
||||||
tag: 'sl-details',
|
private componentId = `details-${++id}`;
|
||||||
styleUrl: 'details.scss',
|
private details: HTMLElement;
|
||||||
shadow: true
|
private header: HTMLElement;
|
||||||
})
|
private isVisible = false;
|
||||||
export class Details {
|
|
||||||
body: HTMLElement;
|
|
||||||
componentId = `details-${++id}`;
|
|
||||||
details: HTMLElement;
|
|
||||||
header: HTMLElement;
|
|
||||||
isVisible = false;
|
|
||||||
|
|
||||||
/** Indicates whether or not the details is open. You can use this in lieu of the show/hide methods. */
|
/** Indicates whether or not the details is open. You can use this in lieu of the show/hide methods. */
|
||||||
@Prop({ mutable: true, reflect: true }) open = false;
|
open = false;
|
||||||
|
|
||||||
/** The summary to show in the details header. If you need to display HTML, use the `summary` slot instead. */
|
/** The summary to show in the details header. If you need to display HTML, use the `summary` slot instead. */
|
||||||
@Prop() summary = '';
|
summary = '';
|
||||||
|
|
||||||
/** Set to true to prevent the user from toggling the details. */
|
/** Disables the details so it can't be toggled. */
|
||||||
@Prop() disabled = false;
|
disabled = false;
|
||||||
|
|
||||||
@Watch('open')
|
onReady() {
|
||||||
handleOpenChange() {
|
|
||||||
this.open ? this.show() : this.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Emitted when the details opens. Calling `event.preventDefault()` will prevent it from being opened. */
|
|
||||||
@Event({ eventName: 'sl-show' }) slShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the details opens and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-show' }) slAfterShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the details closes. Calling `event.preventDefault()` will prevent it from being closed. */
|
|
||||||
@Event({ eventName: 'sl-hide' }) slHide: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the details closes and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-hide' }) slAfterHide: EventEmitter;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleBodyTransitionEnd = this.handleBodyTransitionEnd.bind(this);
|
|
||||||
this.handleSummaryClick = this.handleSummaryClick.bind(this);
|
|
||||||
this.handleSummaryKeyDown = this.handleSummaryKeyDown.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidLoad() {
|
|
||||||
focusVisible.observe(this.details);
|
focusVisible.observe(this.details);
|
||||||
|
|
||||||
this.body.hidden = !this.open;
|
this.body.hidden = !this.open;
|
||||||
|
@ -72,19 +56,18 @@ export class Details {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
onDisconnect() {
|
||||||
focusVisible.unobserve(this.details);
|
focusVisible.unobserve(this.details);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows the alert. */
|
/** Shows the alert. */
|
||||||
@Method()
|
show() {
|
||||||
async show() {
|
|
||||||
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
||||||
if (this.isVisible) {
|
if (this.isVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slShow = this.slShow.emit();
|
const slShow = this.emit('sl-show');
|
||||||
if (slShow.defaultPrevented) {
|
if (slShow.defaultPrevented) {
|
||||||
this.open = false;
|
this.open = false;
|
||||||
return;
|
return;
|
||||||
|
@ -107,14 +90,13 @@ export class Details {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hides the alert */
|
/** Hides the alert */
|
||||||
@Method()
|
hide() {
|
||||||
async hide() {
|
|
||||||
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
||||||
if (!this.isVisible) {
|
if (!this.isVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slHide = this.slHide.emit();
|
const slHide = this.emit('sl-hide');
|
||||||
if (slHide.defaultPrevented) {
|
if (slHide.defaultPrevented) {
|
||||||
this.open = true;
|
this.open = true;
|
||||||
return;
|
return;
|
||||||
|
@ -140,7 +122,7 @@ export class Details {
|
||||||
if (event.propertyName === 'height' && target.classList.contains('details__body')) {
|
if (event.propertyName === 'height' && target.classList.contains('details__body')) {
|
||||||
this.body.style.overflow = this.open ? 'visible' : 'hidden';
|
this.body.style.overflow = this.open ? 'visible' : 'hidden';
|
||||||
this.body.style.height = this.open ? 'auto' : '0';
|
this.body.style.height = this.open ? 'auto' : '0';
|
||||||
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
|
this.open ? this.emit('sl-after-show') : this.emit('sl-after-hide');
|
||||||
this.body.hidden = !this.open;
|
this.body.hidden = !this.open;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -169,32 +151,36 @@ export class Details {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watchOpen() {
|
||||||
|
this.open ? this.show() : this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return html`
|
||||||
<div
|
<div
|
||||||
ref={el => (this.details = el)}
|
ref=${(el: HTMLElement) => (this.details = el)}
|
||||||
part="base"
|
part="base"
|
||||||
class={{
|
class=${classMap({
|
||||||
details: true,
|
details: true,
|
||||||
'details--open': this.open,
|
'details--open': this.open,
|
||||||
'details--disabled': this.disabled
|
'details--disabled': this.disabled
|
||||||
}}
|
})}
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
ref={el => (this.header = el)}
|
ref=${(el: HTMLElement) => (this.header = el)}
|
||||||
part="header"
|
part="header"
|
||||||
id={`${this.componentId}-header`}
|
id=${`${this.componentId}-header`}
|
||||||
class="details__header"
|
class="details__header"
|
||||||
role="button"
|
role="button"
|
||||||
aria-expanded={this.open ? 'true' : 'false'}
|
aria-expanded=${this.open ? 'true' : 'false'}
|
||||||
aria-controls={`${this.componentId}-content`}
|
aria-controls=${`${this.componentId}-content`}
|
||||||
aria-disabled={this.disabled ? 'true' : 'false'}
|
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||||
tabIndex={this.disabled ? -1 : 0}
|
tabindex=${this.disabled ? '-1' : '0'}
|
||||||
onClick={this.handleSummaryClick}
|
onclick=${this.handleSummaryClick.bind(this)}
|
||||||
onKeyDown={this.handleSummaryKeyDown}
|
onkeydown=${this.handleSummaryKeyDown.bind(this)}
|
||||||
>
|
>
|
||||||
<div part="summary" class="details__summary">
|
<div part="summary" class="details__summary">
|
||||||
<slot name="summary">{this.summary}</slot>
|
<slot name="summary">${this.summary}</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span part="summary-icon" class="details__summary-icon">
|
<span part="summary-icon" class="details__summary-icon">
|
||||||
|
@ -202,18 +188,22 @@ export class Details {
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div ref={el => (this.body = el)} class="details__body" onTransitionEnd={this.handleBodyTransitionEnd}>
|
<div
|
||||||
|
ref=${(el: HTMLElement) => (this.body = el)}
|
||||||
|
class="details__body"
|
||||||
|
ontransitionend=${this.handleBodyTransitionEnd.bind(this)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
part="content"
|
part="content"
|
||||||
id={`${this.componentId}-content`}
|
id=${`${this.componentId}-content`}
|
||||||
class="details__content"
|
class="details__content"
|
||||||
role="region"
|
role="region"
|
||||||
aria-labelledby={`${this.componentId}-header`}
|
aria-labelledby=${`${this.componentId}-header`}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,109 +0,0 @@
|
||||||
import { newE2EPage } from '@stencil/core/testing';
|
|
||||||
|
|
||||||
describe('<sl-dialog>', () => {
|
|
||||||
it('should open when the open attribute added', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dialog>This is a dialog.</sl-dialog>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dialog = await page.find('sl-dialog');
|
|
||||||
const base = await page.find('sl-dialog >>> .dialog');
|
|
||||||
const slShow = await dialog.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await dialog.spyOnEvent('sl-after-show');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
|
|
||||||
const showEventHappened = dialog.waitForEvent('sl-after-show');
|
|
||||||
dialog.setAttribute('open', '');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close when the open attribute is removed', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dialog open>This is a dialog.</sl-dialog>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dialog = await page.find('sl-dialog');
|
|
||||||
const base = await page.find('sl-dialog >>> .dialog');
|
|
||||||
const slHide = await dialog.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await dialog.spyOnEvent('sl-after-hide');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const hideEventHappened = dialog.waitForEvent('sl-after-hide');
|
|
||||||
dialog.removeAttribute('open');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await hideEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open when the show() method is called', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dialog>This is a dialog.</sl-dialog>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dialog = await page.find('sl-dialog');
|
|
||||||
const base = await page.find('sl-dialog >>> .dialog');
|
|
||||||
const slShow = await dialog.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await dialog.spyOnEvent('sl-after-show');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
|
|
||||||
const showEventHappened = dialog.waitForEvent('sl-after-show');
|
|
||||||
await dialog.callMethod('show');
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close when the hide() method is called', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dialog open>This is a dialog.</sl-dialog>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dialog = await page.find('sl-dialog');
|
|
||||||
const base = await page.find('sl-dialog >>> .dialog');
|
|
||||||
const slHide = await dialog.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await dialog.spyOnEvent('sl-after-hide');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const hideEventHappened = dialog.waitForEvent('sl-after-hide');
|
|
||||||
await dialog.callMethod('hide');
|
|
||||||
await hideEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-overlay-dismiss when the overlay is clicked', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dialog open>This is a dialog.</sl-dialog>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dialog = await page.find('sl-dialog');
|
|
||||||
const slOverlayDismiss = await dialog.spyOnEvent('sl-overlay-dismiss');
|
|
||||||
|
|
||||||
// We can't use the click method on the overlay since the click is in the middle, which will be behind the panel
|
|
||||||
await page.mouse.click(0, 0);
|
|
||||||
await page.waitForChanges();
|
|
||||||
|
|
||||||
expect(slOverlayDismiss).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,5 +1,5 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
@import 'mixins/hidden';
|
@use '../../styles/mixins/hide';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @prop --width: The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
|
* @prop --width: The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
z-index: var(--sl-z-index-dialog);
|
z-index: var(--sl-z-index-dialog);
|
||||||
|
|
||||||
&:not(.dialog--visible) {
|
&:not(.dialog--visible) {
|
||||||
@include hidden;
|
@include hide.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./dialog.scss';
|
||||||
|
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
|
||||||
|
import { hasSlot } from '../../internal/slot';
|
||||||
|
import { isPreventScrollSupported } from '../../internal/support';
|
||||||
|
import Modal from '../../internal/modal';
|
||||||
|
|
||||||
|
const hasPreventScroll = isPreventScrollSupported();
|
||||||
|
|
||||||
|
let id = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @since 2.0
|
||||||
|
* @status stable
|
||||||
|
*
|
||||||
|
* @dependency sl-icon-button
|
||||||
|
*
|
||||||
|
* @slot - The dialog's content.
|
||||||
|
* @slot label - The dialog's label. Alternatively, you can use the label prop.
|
||||||
|
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
|
||||||
|
*
|
||||||
|
* @part base - The component's base wrapper.
|
||||||
|
* @part overlay - The overlay.
|
||||||
|
* @part panel - The dialog panel (where the dialog and its content is rendered).
|
||||||
|
* @part header - The dialog header.
|
||||||
|
* @part title - The dialog title.
|
||||||
|
* @part close-button - The close button.
|
||||||
|
* @part body - The dialog body.
|
||||||
|
* @part footer - The dialog footer.
|
||||||
|
*
|
||||||
|
* @emit sl-show - Emitted when the dialog opens. Calling `event.preventDefault()` will prevent it from being opened.
|
||||||
|
* @emit sl-after-show - Emitted after the dialog opens and all transitions are complete.
|
||||||
|
* @emit sl-hide - Emitted when the dialog closes. Calling `event.preventDefault()` will prevent it from being closed.
|
||||||
|
* @emit sl-after-hide - Emitted after the dialog closes and all transitions are complete.
|
||||||
|
* @emit sl-initial-focus - Emitted when the dialog opens and the panel gains focus. Calling `event.preventDefault()`
|
||||||
|
* will prevent focus and allow you to set it on a different element in the dialog, such as an input or button.
|
||||||
|
* @emit sl-overlay-dismiss - Emitted when the overlay is clicked. Calling `event.preventDefault()` will prevent the
|
||||||
|
* dialog from closing.
|
||||||
|
*/
|
||||||
|
export default class SlDialog extends Shoemaker {
|
||||||
|
static tag = 'sl-dialog';
|
||||||
|
static props = ['hasFooter', 'isVisible', 'open', 'label', 'noHeader'];
|
||||||
|
static reflect = ['open'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
private componentId = `dialog-${++id}`;
|
||||||
|
private dialog: HTMLElement;
|
||||||
|
private hasFooter = false;
|
||||||
|
private isVisible = false;
|
||||||
|
private modal: Modal;
|
||||||
|
private panel: HTMLElement;
|
||||||
|
private willShow = false;
|
||||||
|
private willHide = false;
|
||||||
|
|
||||||
|
/** Indicates whether or not the dialog is open. You can use this in lieu of the show/hide methods. */
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
noHeader = false;
|
||||||
|
|
||||||
|
onConnect() {
|
||||||
|
this.modal = new Modal(this, {
|
||||||
|
onfocusOut: () => this.panel.focus()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.handleSlotChange();
|
||||||
|
|
||||||
|
// Show on init if open
|
||||||
|
if (this.open) {
|
||||||
|
this.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisconnect() {
|
||||||
|
unlockBodyScrolling(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the dialog */
|
||||||
|
show() {
|
||||||
|
if (this.willShow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slShow = this.emit('sl-show');
|
||||||
|
if (slShow.defaultPrevented) {
|
||||||
|
this.open = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.willShow = true;
|
||||||
|
this.isVisible = true;
|
||||||
|
this.open = true;
|
||||||
|
this.modal.activate();
|
||||||
|
|
||||||
|
lockBodyScrolling(this);
|
||||||
|
|
||||||
|
if (this.open) {
|
||||||
|
if (hasPreventScroll) {
|
||||||
|
// Wait for the next frame before setting initial focus so the dialog is technically visible
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const slInitialFocus = this.emit('sl-initial-focus');
|
||||||
|
if (!slInitialFocus.defaultPrevented) {
|
||||||
|
this.panel.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Once Safari supports { preventScroll: true } we can remove this nasty little hack, but until then we need to
|
||||||
|
// wait for the transition to complete before setting focus, otherwise the panel may render in a buggy way its
|
||||||
|
// out of view initially.
|
||||||
|
//
|
||||||
|
// Fiddle: https://jsfiddle.net/g6buoafq/1/
|
||||||
|
// Safari: https://bugs.webkit.org/show_bug.cgi?id=178583
|
||||||
|
//
|
||||||
|
this.dialog.addEventListener(
|
||||||
|
'transitionend',
|
||||||
|
() => {
|
||||||
|
const slInitialFocus = this.emit('sl-initial-focus');
|
||||||
|
if (!slInitialFocus.defaultPrevented) {
|
||||||
|
this.panel.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hides the dialog */
|
||||||
|
hide() {
|
||||||
|
if (this.willHide) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slHide = this.emit('sl-hide');
|
||||||
|
if (slHide.defaultPrevented) {
|
||||||
|
this.open = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.willHide = true;
|
||||||
|
this.open = false;
|
||||||
|
this.modal.deactivate();
|
||||||
|
|
||||||
|
unlockBodyScrolling(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseClick() {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOverlayClick() {
|
||||||
|
const slOverlayDismiss = this.emit('sl-overlay-dismiss');
|
||||||
|
|
||||||
|
if (!slOverlayDismiss.defaultPrevented) {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSlotChange() {
|
||||||
|
this.hasFooter = hasSlot(this, 'footer');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTransitionEnd(event: TransitionEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
// Ensure we only emit one event when the target element is no longer visible
|
||||||
|
if (event.propertyName === 'opacity' && target.classList.contains('dialog__panel')) {
|
||||||
|
this.isVisible = this.open;
|
||||||
|
this.willShow = false;
|
||||||
|
this.willHide = false;
|
||||||
|
this.open ? this.emit('sl-after-show') : this.emit('sl-after-hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchOpen() {
|
||||||
|
this.open ? this.show() : this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
ref=${(el: HTMLElement) => (this.dialog = el)}
|
||||||
|
part="base"
|
||||||
|
class=${classMap({
|
||||||
|
dialog: true,
|
||||||
|
'dialog--open': this.open,
|
||||||
|
'dialog--visible': this.isVisible,
|
||||||
|
'dialog--has-footer': this.hasFooter
|
||||||
|
})}
|
||||||
|
onkeydown=${this.handleKeyDown.bind(this)}
|
||||||
|
ontransitionend=${this.handleTransitionEnd.bind(this)}
|
||||||
|
>
|
||||||
|
<div part="overlay" class="dialog__overlay" onclick=${this.handleOverlayClick.bind(this)} tabindex="-1" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref=${(el: HTMLElement) => (this.panel = el)}
|
||||||
|
part="panel"
|
||||||
|
class="dialog__panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-hidden=${this.open ? 'false' : 'true'}
|
||||||
|
aria-label=${this.noHeader ? this.label : null}
|
||||||
|
aria-labelledby=${!this.noHeader ? `${this.componentId}-title` : null}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
${!this.noHeader
|
||||||
|
? html`
|
||||||
|
<header part="header" class="dialog__header">
|
||||||
|
<span part="title" class="dialog__title" id=${`${this.componentId}-title`}>
|
||||||
|
<slot name="label"> ${this.label || String.fromCharCode(65279)} </slot>
|
||||||
|
</span>
|
||||||
|
<sl-icon-button
|
||||||
|
exportparts="base:close-button"
|
||||||
|
class="dialog__close"
|
||||||
|
name="x"
|
||||||
|
onclick="${this.handleCloseClick.bind(this)}"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
|
||||||
|
<div part="body" class="dialog__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer part="footer" class="dialog__footer">
|
||||||
|
<slot name="footer" onslotchange=${this.handleSlotChange.bind(this)} />
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,273 +0,0 @@
|
||||||
import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
|
|
||||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../utilities/scroll';
|
|
||||||
import { hasSlot } from '../../utilities/slot';
|
|
||||||
import { isPreventScrollSupported } from '../../utilities/support';
|
|
||||||
import Modal from '../../utilities/modal';
|
|
||||||
|
|
||||||
const hasPreventScroll = isPreventScrollSupported();
|
|
||||||
|
|
||||||
let id = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @since 2.0
|
|
||||||
* @status stable
|
|
||||||
*
|
|
||||||
* @slot - The dialog's content.
|
|
||||||
* @slot label - The dialog's label. Alternatively, you can use the label prop.
|
|
||||||
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
|
|
||||||
*
|
|
||||||
* @part base - The component's base wrapper.
|
|
||||||
* @part overlay - The overlay.
|
|
||||||
* @part panel - The dialog panel (where the dialog and its content is rendered).
|
|
||||||
* @part header - The dialog header.
|
|
||||||
* @part title - The dialog title.
|
|
||||||
* @part close-button - The close button.
|
|
||||||
* @part body - The dialog body.
|
|
||||||
* @part footer - The dialog footer.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
tag: 'sl-dialog',
|
|
||||||
styleUrl: 'dialog.scss',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class Dialog {
|
|
||||||
componentId = `dialog-${++id}`;
|
|
||||||
dialog: HTMLElement;
|
|
||||||
modal: Modal;
|
|
||||||
panel: HTMLElement;
|
|
||||||
willShow = false;
|
|
||||||
willHide = false;
|
|
||||||
|
|
||||||
@Element() host: HTMLSlDialogElement;
|
|
||||||
|
|
||||||
@State() hasFooter = false;
|
|
||||||
@State() isVisible = false;
|
|
||||||
|
|
||||||
/** Indicates whether or not the dialog is open. You can use this in lieu of the show/hide methods. */
|
|
||||||
@Prop({ mutable: true, 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.
|
|
||||||
*/
|
|
||||||
@Prop() label = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set to true to disable 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.
|
|
||||||
*/
|
|
||||||
@Prop() noHeader = false;
|
|
||||||
|
|
||||||
@Watch('open')
|
|
||||||
handleOpenChange() {
|
|
||||||
this.open ? this.show() : this.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Emitted when the dialog opens. Calling `event.preventDefault()` will prevent it from being opened. */
|
|
||||||
@Event({ eventName: 'sl-show' }) slShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the dialog opens and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-show' }) slAfterShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the dialog closes. Calling `event.preventDefault()` will prevent it from being closed. */
|
|
||||||
@Event({ eventName: 'sl-hide' }) slHide: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the dialog closes and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-hide' }) slAfterHide: EventEmitter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emitted when the dialog opens and the panel gains focus. Calling `event.preventDefault()` will prevent focus and
|
|
||||||
* allow you to set it on a different element in the dialog, such as an input or button.
|
|
||||||
*/
|
|
||||||
@Event({ eventName: 'sl-initial-focus' }) slInitialFocus: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the overlay is clicked. Calling `event.preventDefault()` will prevent the dialog from closing. */
|
|
||||||
@Event({ eventName: 'sl-overlay-dismiss' }) slOverlayDismiss: EventEmitter;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleCloseClick = this.handleCloseClick.bind(this);
|
|
||||||
this.handleTransitionEnd = this.handleTransitionEnd.bind(this);
|
|
||||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
||||||
this.handleOverlayClick = this.handleOverlayClick.bind(this);
|
|
||||||
this.handleSlotChange = this.handleSlotChange.bind(this);
|
|
||||||
|
|
||||||
this.modal = new Modal(this.host, {
|
|
||||||
onFocusOut: () => this.panel.focus()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillLoad() {
|
|
||||||
this.handleSlotChange();
|
|
||||||
|
|
||||||
// Show on init if open
|
|
||||||
if (this.open) {
|
|
||||||
this.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
unlockBodyScrolling(this.host);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shows the dialog */
|
|
||||||
@Method()
|
|
||||||
async show() {
|
|
||||||
if (this.willShow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slShow = this.slShow.emit();
|
|
||||||
if (slShow.defaultPrevented) {
|
|
||||||
this.open = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.willShow = true;
|
|
||||||
this.isVisible = true;
|
|
||||||
this.open = true;
|
|
||||||
this.modal.activate();
|
|
||||||
|
|
||||||
lockBodyScrolling(this.host);
|
|
||||||
|
|
||||||
if (this.open) {
|
|
||||||
if (hasPreventScroll) {
|
|
||||||
// Wait for the next frame before setting initial focus so the dialog is technically visible
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const slInitialFocus = this.slInitialFocus.emit();
|
|
||||||
if (!slInitialFocus.defaultPrevented) {
|
|
||||||
this.panel.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Once Safari supports { preventScroll: true } we can remove this nasty little hack, but until then we need to
|
|
||||||
// wait for the transition to complete before setting focus, otherwise the panel may render in a buggy way its
|
|
||||||
// out of view initially.
|
|
||||||
//
|
|
||||||
// Fiddle: https://jsfiddle.net/g6buoafq/1/
|
|
||||||
// Safari: https://bugs.webkit.org/show_bug.cgi?id=178583
|
|
||||||
//
|
|
||||||
this.dialog.addEventListener(
|
|
||||||
'transitionend',
|
|
||||||
() => {
|
|
||||||
const slInitialFocus = this.slInitialFocus.emit();
|
|
||||||
if (!slInitialFocus.defaultPrevented) {
|
|
||||||
this.panel.focus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hides the dialog */
|
|
||||||
@Method()
|
|
||||||
async hide() {
|
|
||||||
if (this.willHide) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slHide = this.slHide.emit();
|
|
||||||
if (slHide.defaultPrevented) {
|
|
||||||
this.open = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.willHide = true;
|
|
||||||
this.open = false;
|
|
||||||
this.modal.deactivate();
|
|
||||||
|
|
||||||
unlockBodyScrolling(this.host);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCloseClick() {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleOverlayClick() {
|
|
||||||
const slOverlayDismiss = this.slOverlayDismiss.emit();
|
|
||||||
|
|
||||||
if (!slOverlayDismiss.defaultPrevented) {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSlotChange() {
|
|
||||||
this.hasFooter = hasSlot(this.host, 'footer');
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTransitionEnd(event: TransitionEvent) {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
|
|
||||||
// Ensure we only emit one event when the target element is no longer visible
|
|
||||||
if (event.propertyName === 'opacity' && target.classList.contains('dialog__panel')) {
|
|
||||||
this.isVisible = this.open;
|
|
||||||
this.willShow = false;
|
|
||||||
this.willHide = false;
|
|
||||||
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={el => (this.dialog = el)}
|
|
||||||
part="base"
|
|
||||||
class={{
|
|
||||||
dialog: true,
|
|
||||||
'dialog--open': this.open,
|
|
||||||
'dialog--visible': this.isVisible,
|
|
||||||
'dialog--has-footer': this.hasFooter
|
|
||||||
}}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
onTransitionEnd={this.handleTransitionEnd}
|
|
||||||
>
|
|
||||||
<div part="overlay" class="dialog__overlay" onClick={this.handleOverlayClick} tabIndex={-1} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={el => (this.panel = el)}
|
|
||||||
part="panel"
|
|
||||||
class="dialog__panel"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-hidden={this.open ? 'false' : 'true'}
|
|
||||||
aria-label={this.noHeader ? this.label : null}
|
|
||||||
aria-labelledby={!this.noHeader ? `${this.componentId}-title` : null}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{!this.noHeader && (
|
|
||||||
<header part="header" class="dialog__header">
|
|
||||||
<span part="title" class="dialog__title" id={`${this.componentId}-title`}>
|
|
||||||
<slot name="label">
|
|
||||||
{/* If there's no label, use an invisible character to prevent the heading from collapsing */}
|
|
||||||
{this.label || String.fromCharCode(65279)}
|
|
||||||
</slot>
|
|
||||||
</span>
|
|
||||||
<sl-icon-button
|
|
||||||
exportparts="base:close-button"
|
|
||||||
class="dialog__close"
|
|
||||||
name="x"
|
|
||||||
onClick={this.handleCloseClick}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div part="body" class="dialog__body">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer part="footer" class="dialog__footer">
|
|
||||||
<slot name="footer" onSlotchange={this.handleSlotChange} />
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,109 +0,0 @@
|
||||||
import { newE2EPage } from '@stencil/core/testing';
|
|
||||||
|
|
||||||
describe('<sl-drawer>', () => {
|
|
||||||
it('should open when the open attribute added', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-drawer>This is a drawer.</sl-drawer>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const drawer = await page.find('sl-drawer');
|
|
||||||
const base = await page.find('sl-drawer >>> .drawer');
|
|
||||||
const slShow = await drawer.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await drawer.spyOnEvent('sl-after-show');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
|
|
||||||
const showEventHappened = drawer.waitForEvent('sl-after-show');
|
|
||||||
drawer.setAttribute('open', '');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close when the open attribute is removed', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-drawer open>This is a drawer.</sl-drawer>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const drawer = await page.find('sl-drawer');
|
|
||||||
const base = await page.find('sl-drawer >>> .drawer');
|
|
||||||
const slHide = await drawer.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await drawer.spyOnEvent('sl-after-hide');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const hideEventHappened = drawer.waitForEvent('sl-after-hide');
|
|
||||||
drawer.removeAttribute('open');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await hideEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open when the show() method is called', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-drawer>This is a drawer.</sl-drawer>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const drawer = await page.find('sl-drawer');
|
|
||||||
const base = await page.find('sl-drawer >>> .drawer');
|
|
||||||
const slShow = await drawer.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await drawer.spyOnEvent('sl-after-show');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
|
|
||||||
const showEventHappened = drawer.waitForEvent('sl-after-show');
|
|
||||||
await drawer.callMethod('show');
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close when the hide() method is called', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-drawer open>This is a drawer.</sl-drawer>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const drawer = await page.find('sl-drawer');
|
|
||||||
const base = await page.find('sl-drawer >>> .drawer');
|
|
||||||
const slHide = await drawer.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await drawer.spyOnEvent('sl-after-hide');
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const hideEventHappened = drawer.waitForEvent('sl-after-hide');
|
|
||||||
await drawer.callMethod('hide');
|
|
||||||
await hideEventHappened;
|
|
||||||
|
|
||||||
expect(await base.isVisible()).toBe(false);
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-overlay-dismiss when the overlay is clicked', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-drawer open>This is a drawer.</sl-drawer>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const drawer = await page.find('sl-drawer');
|
|
||||||
const slOverlayDismiss = await drawer.spyOnEvent('sl-overlay-dismiss');
|
|
||||||
|
|
||||||
// We can't use the click method on the overlay since the click is in the middle, which will be behind the panel
|
|
||||||
await page.mouse.click(0, 0);
|
|
||||||
await page.waitForChanges();
|
|
||||||
|
|
||||||
expect(slOverlayDismiss).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,5 +1,5 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
@import 'mixins/hidden';
|
@use '../../styles/mixins/hide';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @prop --size: The preferred size of the drawer. This will be applied to the drawer's width or height depending on its
|
* @prop --size: The preferred size of the drawer. This will be applied to the drawer's width or height depending on its
|
||||||
|
@ -20,7 +20,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&:not(.drawer--visible) {
|
&:not(.drawer--visible) {
|
||||||
@include hidden;
|
@include hide.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../utilities/scroll';
|
import styles from 'sass:./drawer.scss';
|
||||||
import { hasSlot } from '../../utilities/slot';
|
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
|
||||||
import { isPreventScrollSupported } from '../../utilities/support';
|
import { hasSlot } from '../../internal/slot';
|
||||||
import Modal from '../../utilities/modal';
|
import { isPreventScrollSupported } from '../../internal/support';
|
||||||
|
import Modal from '../../internal/modal';
|
||||||
|
|
||||||
const hasPreventScroll = isPreventScrollSupported();
|
const hasPreventScroll = isPreventScrollSupported();
|
||||||
|
|
||||||
|
@ -12,6 +13,8 @@ let id = 0;
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
* @status stable
|
* @status stable
|
||||||
*
|
*
|
||||||
|
* @dependency sl-icon-button
|
||||||
|
*
|
||||||
* @slot - The drawer's content.
|
* @slot - The drawer's content.
|
||||||
* @slot label - The drawer's label. Alternatively, you can use the label prop.
|
* @slot label - The drawer's label. Alternatively, you can use the label prop.
|
||||||
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
|
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
|
||||||
|
@ -24,88 +27,60 @@ let id = 0;
|
||||||
* @part close-button - The close button.
|
* @part close-button - The close button.
|
||||||
* @part body - The drawer body.
|
* @part body - The drawer body.
|
||||||
* @part footer - The drawer footer.
|
* @part footer - The drawer footer.
|
||||||
|
*
|
||||||
|
* @emit sl-show - Emitted when the drawer opens. Calling `event.preventDefault()` will prevent it from being opened.
|
||||||
|
* @emit sl-after-show - Emitted after the drawer opens and all transitions are complete.
|
||||||
|
* @emit sl-hide - Emitted when the drawer closes. Calling `event.preventDefault()` will prevent it from being closed.
|
||||||
|
* @emit sl-after-hide - Emitted after the drawer closes and all transitions are complete.
|
||||||
|
* @emit sl-initial-focus - Emitted when the drawer opens and the panel gains focus. Calling `event.preventDefault()`
|
||||||
|
* will prevent focus and allow you to set it on a different element in the drawer, such as an input or button.
|
||||||
|
* @emit sl-overlay-dismiss - Emitted when the overlay is clicked. Calling `event.preventDefault()` will prevent the
|
||||||
|
* drawer from closing.
|
||||||
*/
|
*/
|
||||||
@Component({
|
export default class SlDrawer extends Shoemaker {
|
||||||
tag: 'sl-drawer',
|
static tag = 'sl-drawer';
|
||||||
styleUrl: 'drawer.scss',
|
static props = ['hasFooter', 'isVisible', 'open', 'label', 'placement', 'contained', 'noHeader'];
|
||||||
shadow: true
|
static reflect = ['open'];
|
||||||
})
|
static styles = styles;
|
||||||
export class Drawer {
|
|
||||||
componentId = `drawer-${++id}`;
|
|
||||||
drawer: HTMLElement;
|
|
||||||
modal: Modal;
|
|
||||||
panel: HTMLElement;
|
|
||||||
willShow = false;
|
|
||||||
willHide = false;
|
|
||||||
|
|
||||||
@Element() host: HTMLSlDrawerElement;
|
private componentId = `drawer-${++id}`;
|
||||||
|
private drawer: HTMLElement;
|
||||||
@State() hasFooter = false;
|
private hasFooter = false;
|
||||||
@State() isVisible = false;
|
private isVisible = false;
|
||||||
|
private modal: Modal;
|
||||||
|
private panel: HTMLElement;
|
||||||
|
private willShow = false;
|
||||||
|
private willHide = false;
|
||||||
|
|
||||||
/** Indicates whether or not the drawer is open. You can use this in lieu of the show/hide methods. */
|
/** Indicates whether or not the drawer is open. You can use this in lieu of the show/hide methods. */
|
||||||
@Prop({ mutable: true, reflect: true }) open = false;
|
open = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The drawer's label as displayed in the header. You should always include a relevant label even when using
|
* 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.
|
* `no-header`, as it is required for proper accessibility.
|
||||||
*/
|
*/
|
||||||
@Prop() label = '';
|
label = '';
|
||||||
|
|
||||||
/** The direction from which the drawer will open. */
|
/** The direction from which the drawer will open. */
|
||||||
@Prop() placement: 'top' | 'right' | 'bottom' | 'left' = 'right';
|
placement: 'top' | 'right' | 'bottom' | 'left' = 'right';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* By default, the drawer slides out of its containing block (usually the viewport). To make the drawer slide out of
|
* 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 prop and add `position: relative` to the parent.
|
* its parent element, set this prop and add `position: relative` to the parent.
|
||||||
*/
|
*/
|
||||||
@Prop() contained = false;
|
contained = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the header. This will also remove the default close button, so please ensure you provide an easy,
|
* 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.
|
* accessible way for users to dismiss the drawer.
|
||||||
*/
|
*/
|
||||||
@Prop() noHeader = false;
|
noHeader = false;
|
||||||
|
|
||||||
@Watch('open')
|
onConnect() {
|
||||||
handleOpenChange() {
|
this.modal = new Modal(this, {
|
||||||
this.open ? this.show() : this.hide();
|
onfocusOut: () => (this.contained ? null : this.panel.focus())
|
||||||
}
|
|
||||||
|
|
||||||
/** Emitted when the drawer opens. Calling `event.preventDefault()` will prevent it from being opened. */
|
|
||||||
@Event({ eventName: 'sl-show' }) slShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the drawer opens and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-show' }) slAfterShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the drawer closes. Calling `event.preventDefault()` will prevent it from being closed. */
|
|
||||||
@Event({ eventName: 'sl-hide' }) slHide: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the drawer closes and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-hide' }) slAfterHide: EventEmitter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emitted when the drawer opens and the panel gains focus. Calling `event.preventDefault()` will prevent focus and
|
|
||||||
* allow you to set it on a different element in the drawer, such as an input or button.
|
|
||||||
*/
|
|
||||||
@Event({ eventName: 'sl-initial-focus' }) slInitialFocus: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the overlay is clicked. Calling `event.preventDefault()` will prevent the drawer from closing. */
|
|
||||||
@Event({ eventName: 'sl-overlay-dismiss' }) slOverlayDismiss: EventEmitter;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.handleCloseClick = this.handleCloseClick.bind(this);
|
|
||||||
this.handleTransitionEnd = this.handleTransitionEnd.bind(this);
|
|
||||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
||||||
this.handleOverlayClick = this.handleOverlayClick.bind(this);
|
|
||||||
this.handleSlotChange = this.handleSlotChange.bind(this);
|
|
||||||
|
|
||||||
this.modal = new Modal(this.host, {
|
|
||||||
onFocusOut: () => (this.contained ? null : this.panel.focus())
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
componentWillLoad() {
|
|
||||||
this.handleSlotChange();
|
this.handleSlotChange();
|
||||||
|
|
||||||
// Show on init if open
|
// Show on init if open
|
||||||
|
@ -114,18 +89,17 @@ export class Drawer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
onDisconnect() {
|
||||||
unlockBodyScrolling(this.host);
|
unlockBodyScrolling(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows the drawer */
|
/** Shows the drawer */
|
||||||
@Method()
|
show() {
|
||||||
async show() {
|
|
||||||
if (this.willShow) {
|
if (this.willShow) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slShow = this.slShow.emit();
|
const slShow = this.emit('sl-show');
|
||||||
if (slShow.defaultPrevented) {
|
if (slShow.defaultPrevented) {
|
||||||
this.open = false;
|
this.open = false;
|
||||||
return;
|
return;
|
||||||
|
@ -138,14 +112,14 @@ export class Drawer {
|
||||||
// Lock body scrolling only if the drawer isn't contained
|
// Lock body scrolling only if the drawer isn't contained
|
||||||
if (!this.contained) {
|
if (!this.contained) {
|
||||||
this.modal.activate();
|
this.modal.activate();
|
||||||
lockBodyScrolling(this.host);
|
lockBodyScrolling(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.open) {
|
if (this.open) {
|
||||||
if (hasPreventScroll) {
|
if (hasPreventScroll) {
|
||||||
// Wait for the next frame before setting initial focus so the dialog is technically visible
|
// Wait for the next frame before setting initial focus so the drawer is technically visible
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const slInitialFocus = this.slInitialFocus.emit();
|
const slInitialFocus = this.emit('sl-initial-focus');
|
||||||
if (!slInitialFocus.defaultPrevented) {
|
if (!slInitialFocus.defaultPrevented) {
|
||||||
this.panel.focus({ preventScroll: true });
|
this.panel.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
@ -161,7 +135,7 @@ export class Drawer {
|
||||||
this.drawer.addEventListener(
|
this.drawer.addEventListener(
|
||||||
'transitionend',
|
'transitionend',
|
||||||
() => {
|
() => {
|
||||||
const slInitialFocus = this.slInitialFocus.emit();
|
const slInitialFocus = this.emit('sl-initial-focus');
|
||||||
if (!slInitialFocus.defaultPrevented) {
|
if (!slInitialFocus.defaultPrevented) {
|
||||||
this.panel.focus();
|
this.panel.focus();
|
||||||
}
|
}
|
||||||
|
@ -173,13 +147,12 @@ export class Drawer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hides the drawer */
|
/** Hides the drawer */
|
||||||
@Method()
|
hide() {
|
||||||
async hide() {
|
|
||||||
if (this.willHide) {
|
if (this.willHide) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slHide = this.slHide.emit();
|
const slHide = this.emit('sl-hide');
|
||||||
if (slHide.defaultPrevented) {
|
if (slHide.defaultPrevented) {
|
||||||
this.open = true;
|
this.open = true;
|
||||||
return;
|
return;
|
||||||
|
@ -189,7 +162,7 @@ export class Drawer {
|
||||||
this.open = false;
|
this.open = false;
|
||||||
this.modal.deactivate();
|
this.modal.deactivate();
|
||||||
|
|
||||||
unlockBodyScrolling(this.host);
|
unlockBodyScrolling(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCloseClick() {
|
handleCloseClick() {
|
||||||
|
@ -203,7 +176,7 @@ export class Drawer {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOverlayClick() {
|
handleOverlayClick() {
|
||||||
const slOverlayDismiss = this.slOverlayDismiss.emit();
|
const slOverlayDismiss = this.emit('sl-overlay-dismiss');
|
||||||
|
|
||||||
if (!slOverlayDismiss.defaultPrevented) {
|
if (!slOverlayDismiss.defaultPrevented) {
|
||||||
this.hide();
|
this.hide();
|
||||||
|
@ -211,7 +184,7 @@ export class Drawer {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSlotChange() {
|
handleSlotChange() {
|
||||||
this.hasFooter = hasSlot(this.host, 'footer');
|
this.hasFooter = hasSlot(this, 'footer');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTransitionEnd(event: TransitionEvent) {
|
handleTransitionEnd(event: TransitionEvent) {
|
||||||
|
@ -222,16 +195,16 @@ export class Drawer {
|
||||||
this.isVisible = this.open;
|
this.isVisible = this.open;
|
||||||
this.willShow = false;
|
this.willShow = false;
|
||||||
this.willHide = false;
|
this.willHide = false;
|
||||||
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
|
this.open ? this.emit('sl-after-show') : this.emit('sl-after-hide');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return html`
|
||||||
<div
|
<div
|
||||||
ref={el => (this.drawer = el)}
|
ref=${(el: HTMLElement) => (this.drawer = el)}
|
||||||
part="base"
|
part="base"
|
||||||
class={{
|
class=${classMap({
|
||||||
drawer: true,
|
drawer: true,
|
||||||
'drawer--open': this.open,
|
'drawer--open': this.open,
|
||||||
'drawer--visible': this.isVisible,
|
'drawer--visible': this.isVisible,
|
||||||
|
@ -242,49 +215,49 @@ export class Drawer {
|
||||||
'drawer--contained': this.contained,
|
'drawer--contained': this.contained,
|
||||||
'drawer--fixed': !this.contained,
|
'drawer--fixed': !this.contained,
|
||||||
'drawer--has-footer': this.hasFooter
|
'drawer--has-footer': this.hasFooter
|
||||||
}}
|
})}
|
||||||
onKeyDown={this.handleKeyDown}
|
onkeydown=${this.handleKeyDown.bind(this)}
|
||||||
onTransitionEnd={this.handleTransitionEnd}
|
ontransitionend=${this.handleTransitionEnd.bind(this)}
|
||||||
>
|
>
|
||||||
<div part="overlay" class="drawer__overlay" onClick={this.handleOverlayClick} tabIndex={-1} />
|
<div part="overlay" class="drawer__overlay" onclick=${this.handleOverlayClick.bind(this)} tabindex="-1" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={el => (this.panel = el)}
|
ref=${(el: HTMLElement) => (this.panel = el)}
|
||||||
part="panel"
|
part="panel"
|
||||||
class="drawer__panel"
|
class="drawer__panel"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-hidden={this.open ? 'false' : 'true'}
|
aria-hidden=${this.open ? 'false' : 'true'}
|
||||||
aria-label={this.noHeader ? this.label : null}
|
aria-label=${this.noHeader ? this.label : null}
|
||||||
aria-labelledby={!this.noHeader ? `${this.componentId}-title` : null}
|
aria-labelledby=${!this.noHeader ? `${this.componentId}-title` : null}
|
||||||
tabIndex={0}
|
tabindex="0"
|
||||||
>
|
>
|
||||||
{!this.noHeader && (
|
${!this.noHeader
|
||||||
<header part="header" class="drawer__header">
|
? html`
|
||||||
<span part="title" class="drawer__title" id={`${this.componentId}-title`}>
|
<header part="header" class="drawer__header">
|
||||||
<slot name="label">
|
<span part="title" class="drawer__title" id=${`${this.componentId}-title`}>
|
||||||
{/* If there's no label, use an invisible character to prevent the heading from collapsing */}
|
<!-- If there's no label, use an invisible character to prevent the heading from collapsing -->
|
||||||
{this.label || String.fromCharCode(65279)}
|
<slot name="label"> ${this.label || String.fromCharCode(65279)} </slot>
|
||||||
</slot>
|
</span>
|
||||||
</span>
|
<sl-icon-button
|
||||||
<sl-icon-button
|
exportparts="base:close-button"
|
||||||
exportparts="base:close-button"
|
class="drawer__close"
|
||||||
class="drawer__close"
|
name="x"
|
||||||
name="x"
|
onclick=${this.handleCloseClick.bind(this)}
|
||||||
onClick={this.handleCloseClick}
|
/>
|
||||||
/>
|
</header>
|
||||||
</header>
|
`
|
||||||
)}
|
: ''}
|
||||||
|
|
||||||
<div part="body" class="drawer__body">
|
<div part="body" class="drawer__body">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer part="footer" class="drawer__footer">
|
<footer part="footer" class="drawer__footer">
|
||||||
<slot name="footer" onSlotchange={this.handleSlotChange} />
|
<slot name="footer" onslotchange=${this.handleSlotChange.bind(this)} />
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,213 +0,0 @@
|
||||||
import { newE2EPage } from '@stencil/core/testing';
|
|
||||||
|
|
||||||
describe('<sl-dropdown>', () => {
|
|
||||||
it('should open when the open attribute is added', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dropdown>
|
|
||||||
<sl-button slot="trigger" caret>Dropdown</sl-button>
|
|
||||||
<sl-menu>
|
|
||||||
<sl-menu-item>Dropdown Item</sl-menu-item>
|
|
||||||
</sl-menu>
|
|
||||||
</sl-dropdown>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dropdown = await page.find('sl-dropdown');
|
|
||||||
const panel = await page.find('sl-dropdown >>> .dropdown__panel');
|
|
||||||
const slShow = await dropdown.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await dropdown.spyOnEvent('sl-after-show');
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(false);
|
|
||||||
|
|
||||||
const showEventHappened = dropdown.waitForEvent('sl-after-show');
|
|
||||||
dropdown.setAttribute('open', '');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(true);
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close when the open attribute is removed', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dropdown open>
|
|
||||||
<sl-button slot="trigger" caret>Dropdown</sl-button>
|
|
||||||
<sl-menu>
|
|
||||||
<sl-menu-item>Dropdown Item</sl-menu-item>
|
|
||||||
</sl-menu>
|
|
||||||
</sl-dropdown>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dropdown = await page.find('sl-dropdown');
|
|
||||||
const panel = await page.find('sl-dropdown >>> .dropdown__panel');
|
|
||||||
const slHide = await dropdown.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await dropdown.spyOnEvent('sl-after-hide');
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const hideEventHappened = dropdown.waitForEvent('sl-after-hide');
|
|
||||||
dropdown.removeAttribute('open');
|
|
||||||
await page.waitForChanges();
|
|
||||||
await hideEventHappened;
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(false);
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open when the show() method is called', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dropdown>
|
|
||||||
<sl-button slot="trigger" caret>Dropdown</sl-button>
|
|
||||||
<sl-menu>
|
|
||||||
<sl-menu-item>Dropdown Item</sl-menu-item>
|
|
||||||
</sl-menu>
|
|
||||||
</sl-dropdown>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dropdown = await page.find('sl-dropdown');
|
|
||||||
const panel = await page.find('sl-dropdown >>> .dropdown__panel');
|
|
||||||
const slShow = await dropdown.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await dropdown.spyOnEvent('sl-after-show');
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(false);
|
|
||||||
|
|
||||||
const showEventHappened = dropdown.waitForEvent('sl-after-show');
|
|
||||||
await dropdown.callMethod('show');
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(true);
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close when the hide() method is called', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dropdown open>
|
|
||||||
<sl-button slot="trigger" caret>Dropdown</sl-button>
|
|
||||||
<sl-menu>
|
|
||||||
<sl-menu-item>Dropdown Item</sl-menu-item>
|
|
||||||
</sl-menu>
|
|
||||||
</sl-dropdown>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropdown = await page.find('sl-dropdown');
|
|
||||||
const panel = await page.find('sl-dropdown >>> .dropdown__panel');
|
|
||||||
const slHide = await dropdown.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await dropdown.spyOnEvent('sl-after-hide');
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const hideEventHappened = dropdown.waitForEvent('sl-after-hide');
|
|
||||||
await dropdown.callMethod('hide');
|
|
||||||
await hideEventHappened;
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(false);
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should open when clicked and hidden', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dropdown>
|
|
||||||
<sl-button slot="trigger" caret>Dropdown</sl-button>
|
|
||||||
<sl-menu>
|
|
||||||
<sl-menu-item>Dropdown Item</sl-menu-item>
|
|
||||||
</sl-menu>
|
|
||||||
</sl-dropdown>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dropdown = await page.find('sl-dropdown');
|
|
||||||
const panel = await page.find('sl-dropdown >>> .dropdown__panel');
|
|
||||||
const slShow = await dropdown.spyOnEvent('sl-show');
|
|
||||||
const slAfterShow = await dropdown.spyOnEvent('sl-after-show');
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(false);
|
|
||||||
|
|
||||||
const showEventHappened = dropdown.waitForEvent('sl-after-show');
|
|
||||||
await dropdown.click();
|
|
||||||
await showEventHappened;
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(true);
|
|
||||||
expect(slShow).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterShow).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close when clicked while showing', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dropdown open>
|
|
||||||
<sl-button slot="trigger" caret>Dropdown</sl-button>
|
|
||||||
<sl-menu>
|
|
||||||
<sl-menu-item>Dropdown Item</sl-menu-item>
|
|
||||||
</sl-menu>
|
|
||||||
</sl-dropdown>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dropdown = await page.find('sl-dropdown');
|
|
||||||
const panel = await page.find('sl-dropdown >>> .dropdown__panel');
|
|
||||||
const slHide = await dropdown.spyOnEvent('sl-hide');
|
|
||||||
const slAfterHide = await dropdown.spyOnEvent('sl-after-hide');
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const afterEventHappened = dropdown.waitForEvent('sl-after-hide');
|
|
||||||
await dropdown.click();
|
|
||||||
await afterEventHappened;
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(false);
|
|
||||||
expect(slHide).toHaveReceivedEventTimes(1);
|
|
||||||
expect(slAfterHide).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close when an item is selected', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dropdown open>
|
|
||||||
<sl-button slot="trigger" caret>Dropdown</sl-button>
|
|
||||||
<sl-menu>
|
|
||||||
<sl-menu-item>Dropdown Item</sl-menu-item>
|
|
||||||
</sl-menu>
|
|
||||||
</sl-dropdown>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dropdown = await page.find('sl-dropdown');
|
|
||||||
const panel = await page.find('sl-dropdown >>> .dropdown__panel');
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
const eventHappened = dropdown.waitForEvent('sl-after-hide');
|
|
||||||
await panel.click();
|
|
||||||
await eventHappened;
|
|
||||||
|
|
||||||
expect(await panel.isVisible()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not close when an item is selected and closeOnSelect is true', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: `
|
|
||||||
<sl-dropdown open>
|
|
||||||
<sl-button slot="trigger" caret>Dropdown</sl-button>
|
|
||||||
<sl-menu>
|
|
||||||
<sl-menu-item>Dropdown Item</sl-menu-item>
|
|
||||||
</sl-menu>
|
|
||||||
</sl-dropdown>
|
|
||||||
`
|
|
||||||
});
|
|
||||||
const dropdown = await page.find('sl-dropdown');
|
|
||||||
const panel = await page.find('sl-dropdown >>> .dropdown__panel');
|
|
||||||
|
|
||||||
dropdown.setProperty('closeOnSelect', false);
|
|
||||||
await page.waitForChanges();
|
|
||||||
expect(await panel.isVisible()).toBe(true);
|
|
||||||
|
|
||||||
await panel.click();
|
|
||||||
expect(await panel.isVisible()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -18,7 +18,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown__panel {
|
.dropdown__panel {
|
||||||
max-height: 50vh;
|
max-height: 75vh;
|
||||||
font-family: var(--sl-font-sans);
|
font-family: var(--sl-font-sans);
|
||||||
font-size: var(--sl-font-size-medium);
|
font-size: var(--sl-font-size-medium);
|
||||||
font-weight: var(--sl-font-weight-normal);
|
font-weight: var(--sl-font-weight-normal);
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { Component, Element, Event, EventEmitter, Method, Prop, Watch, h } from '@stencil/core';
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
import { scrollIntoView } from '../../utilities/scroll';
|
import styles from 'sass:./dropdown.scss';
|
||||||
import { getNearestTabbableElement } from '../../utilities/tabbable';
|
import { SlMenu, SlMenuItem } from '../../shoelace';
|
||||||
import Popover from '../../utilities/popover';
|
import { scrollIntoView } from '../../internal/scroll';
|
||||||
|
import { getNearestTabbableElement } from '../../internal/tabbable';
|
||||||
|
import Popover from '../../internal/popover';
|
||||||
|
|
||||||
let id = 0;
|
let id = 0;
|
||||||
|
|
||||||
|
@ -15,32 +17,33 @@ let id = 0;
|
||||||
* @part base - The component's base wrapper.
|
* @part base - The component's base wrapper.
|
||||||
* @part trigger - The container that wraps the trigger.
|
* @part trigger - The container that wraps the trigger.
|
||||||
* @part panel - The panel that gets shown when the dropdown is open.
|
* @part panel - The panel that gets shown when the dropdown is open.
|
||||||
|
*
|
||||||
|
* @emit sl-show - Emitted when the dropdown opens. Calling `event.preventDefault()` will prevent it from being opened.
|
||||||
|
* @emit sl-after-show - Emitted after the dropdown opens and all transitions are complete.
|
||||||
|
* @emit sl-hide - Emitted when the dropdown closes. Calling `event.preventDefault()` will prevent it from being closed.
|
||||||
|
* @emit sl-after-hide - Emitted after the dropdown closes and all transitions are complete.
|
||||||
*/
|
*/
|
||||||
|
export default class SlDropdown extends Shoemaker {
|
||||||
|
static tag = 'sl-dropdown';
|
||||||
|
static props = ['open', 'placement', 'closeOnSelect', 'containingElement', 'distance', 'skidding', 'hoist'];
|
||||||
|
static reflect = ['open'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
@Component({
|
private componentId = `dropdown-${++id}`;
|
||||||
tag: 'sl-dropdown',
|
private isVisible = false;
|
||||||
styleUrl: 'dropdown.scss',
|
private panel: HTMLElement;
|
||||||
shadow: true
|
private positioner: HTMLElement;
|
||||||
})
|
private popover: Popover;
|
||||||
export class Dropdown {
|
private trigger: HTMLElement;
|
||||||
accessibleTrigger: HTMLElement;
|
|
||||||
componentId = `dropdown-${++id}`;
|
|
||||||
isVisible = false;
|
|
||||||
panel: HTMLElement;
|
|
||||||
positioner: HTMLElement;
|
|
||||||
popover: Popover;
|
|
||||||
trigger: HTMLElement;
|
|
||||||
|
|
||||||
@Element() host: HTMLSlDropdownElement;
|
|
||||||
|
|
||||||
/** Indicates whether or not the dropdown is open. You can use this in lieu of the show/hide methods. */
|
/** Indicates whether or not the dropdown is open. You can use this in lieu of the show/hide methods. */
|
||||||
@Prop({ mutable: true, reflect: true }) open = false;
|
open = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
|
* The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel
|
||||||
* inside of the viewport.
|
* inside of the viewport.
|
||||||
*/
|
*/
|
||||||
@Prop() placement:
|
placement:
|
||||||
| 'top'
|
| 'top'
|
||||||
| 'top-start'
|
| 'top-start'
|
||||||
| 'top-end'
|
| 'top-end'
|
||||||
|
@ -55,45 +58,23 @@ export class Dropdown {
|
||||||
| 'left-end' = 'bottom-start';
|
| 'left-end' = 'bottom-start';
|
||||||
|
|
||||||
/** Determines whether the dropdown should hide when a menu item is selected. */
|
/** Determines whether the dropdown should hide when a menu item is selected. */
|
||||||
@Prop() closeOnSelect = true;
|
closeOnSelect = true;
|
||||||
|
|
||||||
/** The dropdown will close when the user interacts outside of this element (e.g. clicking). */
|
/** The dropdown will close when the user interacts outside of this element (e.g. clicking). */
|
||||||
@Prop() containingElement: HTMLElement;
|
containingElement: HTMLElement;
|
||||||
|
|
||||||
/** The distance in pixels from which to offset the panel away from its trigger. */
|
/** The distance in pixels from which to offset the panel away from its trigger. */
|
||||||
@Prop() distance = 2;
|
distance = 2;
|
||||||
|
|
||||||
/** The distance in pixels from which to offset the panel along its trigger. */
|
/** The distance in pixels from which to offset the panel along its trigger. */
|
||||||
@Prop() skidding = 0;
|
skidding = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
|
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
|
||||||
* `overflow: auto|scroll`.
|
* `overflow: auto|scroll`.
|
||||||
*/
|
*/
|
||||||
@Prop() hoist = false;
|
hoist = false;
|
||||||
|
|
||||||
/** Emitted when the dropdown opens. Calling `event.preventDefault()` will prevent it from being opened. */
|
|
||||||
@Event({ eventName: 'sl-show' }) slShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the dropdown opens and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-show' }) slAfterShow: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted when the dropdown closes. Calling `event.preventDefault()` will prevent it from being closed. */
|
|
||||||
@Event({ eventName: 'sl-hide' }) slHide: EventEmitter;
|
|
||||||
|
|
||||||
/** Emitted after the dropdown closes and all transitions are complete. */
|
|
||||||
@Event({ eventName: 'sl-after-hide' }) slAfterHide: EventEmitter;
|
|
||||||
|
|
||||||
@Watch('open')
|
|
||||||
handleOpenChange() {
|
|
||||||
this.open ? this.show() : this.hide();
|
|
||||||
this.updateAccessibleTrigger();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch('distance')
|
|
||||||
@Watch('hoist')
|
|
||||||
@Watch('placement')
|
|
||||||
@Watch('skidding')
|
|
||||||
handlePopoverOptionsChange() {
|
handlePopoverOptionsChange() {
|
||||||
this.popover.setOptions({
|
this.popover.setOptions({
|
||||||
strategy: this.hoist ? 'fixed' : 'absolute',
|
strategy: this.hoist ? 'fixed' : 'absolute',
|
||||||
|
@ -103,30 +84,26 @@ export class Dropdown {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
onConnect() {
|
||||||
if (!this.containingElement) {
|
|
||||||
this.containingElement = this.host;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
|
|
||||||
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
|
|
||||||
this.handleMenuItemActivate = this.handleMenuItemActivate.bind(this);
|
this.handleMenuItemActivate = this.handleMenuItemActivate.bind(this);
|
||||||
this.handlePanelSelect = this.handlePanelSelect.bind(this);
|
this.handlePanelSelect = this.handlePanelSelect.bind(this);
|
||||||
this.handleTriggerClick = this.handleTriggerClick.bind(this);
|
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
|
||||||
this.handleTriggerKeyDown = this.handleTriggerKeyDown.bind(this);
|
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
|
||||||
this.handleTriggerKeyUp = this.handleTriggerKeyUp.bind(this);
|
|
||||||
this.handleTriggerSlotChange = this.handleTriggerSlotChange.bind(this);
|
if (!this.containingElement) {
|
||||||
|
this.containingElement = this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidLoad() {
|
onReady() {
|
||||||
this.popover = new Popover(this.trigger, this.positioner, {
|
this.popover = new Popover(this.trigger, this.positioner, {
|
||||||
strategy: this.hoist ? 'fixed' : 'absolute',
|
strategy: this.hoist ? 'fixed' : 'absolute',
|
||||||
placement: this.placement,
|
placement: this.placement,
|
||||||
distance: this.distance,
|
distance: this.distance,
|
||||||
skidding: this.skidding,
|
skidding: this.skidding,
|
||||||
transitionElement: this.panel,
|
transitionElement: this.panel,
|
||||||
onAfterHide: () => this.slAfterHide.emit(),
|
onAfterHide: () => this.emit('sl-after-hide'),
|
||||||
onAfterShow: () => this.slAfterShow.emit(),
|
onAfterShow: () => this.emit('sl-after-show'),
|
||||||
onTransitionEnd: () => {
|
onTransitionEnd: () => {
|
||||||
if (!this.open) {
|
if (!this.open) {
|
||||||
this.panel.scrollTop = 0;
|
this.panel.scrollTop = 0;
|
||||||
|
@ -140,71 +117,13 @@ export class Dropdown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
onDisconnect() {
|
||||||
this.hide();
|
this.hide();
|
||||||
this.popover.destroy();
|
this.popover.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows the dropdown panel */
|
|
||||||
@Method()
|
|
||||||
async show() {
|
|
||||||
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
|
||||||
if (this.isVisible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slShow = this.slShow.emit();
|
|
||||||
if (slShow.defaultPrevented) {
|
|
||||||
this.open = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.panel.addEventListener('sl-activate', this.handleMenuItemActivate);
|
|
||||||
this.panel.addEventListener('sl-select', this.handlePanelSelect);
|
|
||||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
|
||||||
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
|
||||||
|
|
||||||
this.isVisible = true;
|
|
||||||
this.open = true;
|
|
||||||
this.popover.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hides the dropdown panel */
|
|
||||||
@Method()
|
|
||||||
async hide() {
|
|
||||||
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
|
||||||
if (!this.isVisible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slHide = this.slHide.emit();
|
|
||||||
if (slHide.defaultPrevented) {
|
|
||||||
this.open = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.panel.removeEventListener('sl-activate', this.handleMenuItemActivate);
|
|
||||||
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
|
|
||||||
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
|
||||||
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
|
||||||
|
|
||||||
this.isVisible = false;
|
|
||||||
this.open = false;
|
|
||||||
this.popover.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forces the dropdown's menu to reposition. */
|
|
||||||
@Method()
|
|
||||||
async reposition() {
|
|
||||||
if (!this.open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.popover.reposition();
|
|
||||||
}
|
|
||||||
|
|
||||||
focusOnTrigger() {
|
focusOnTrigger() {
|
||||||
const slot = this.trigger.querySelector('slot');
|
const slot = this.trigger.querySelector('slot')!;
|
||||||
const trigger = slot.assignedElements({ flatten: true })[0] as any;
|
const trigger = slot.assignedElements({ flatten: true })[0] as any;
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
if (typeof trigger.setFocus === 'function') {
|
if (typeof trigger.setFocus === 'function') {
|
||||||
|
@ -216,10 +135,8 @@ export class Dropdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
getMenu() {
|
getMenu() {
|
||||||
return this.panel
|
const slot = this.panel.querySelector('slot')!;
|
||||||
.querySelector('slot')
|
return slot.assignedElements({ flatten: true }).filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as SlMenu;
|
||||||
.assignedElements({ flatten: true })
|
|
||||||
.filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as HTMLSlMenuElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDocumentKeyDown(event: KeyboardEvent) {
|
handleDocumentKeyDown(event: KeyboardEvent) {
|
||||||
|
@ -247,7 +164,7 @@ export class Dropdown {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const activeElement =
|
const activeElement =
|
||||||
this.containingElement.getRootNode() instanceof ShadowRoot
|
this.containingElement.getRootNode() instanceof ShadowRoot
|
||||||
? document.activeElement.shadowRoot?.activeElement
|
? document.activeElement?.shadowRoot?.activeElement
|
||||||
: document.activeElement;
|
: document.activeElement;
|
||||||
|
|
||||||
if (activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement) {
|
if (activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement) {
|
||||||
|
@ -268,7 +185,7 @@ export class Dropdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMenuItemActivate(event: CustomEvent) {
|
handleMenuItemActivate(event: CustomEvent) {
|
||||||
const item = event.target as HTMLSlMenuItemElement;
|
const item = event.target as SlMenuItem;
|
||||||
scrollIntoView(item, this.panel);
|
scrollIntoView(item, this.panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -288,7 +205,7 @@ export class Dropdown {
|
||||||
|
|
||||||
handleTriggerKeyDown(event: KeyboardEvent) {
|
handleTriggerKeyDown(event: KeyboardEvent) {
|
||||||
const menu = this.getMenu();
|
const menu = this.getMenu();
|
||||||
const menuItems = menu ? [...menu.querySelectorAll('sl-menu-item')] : null;
|
const menuItems = menu ? ([...menu.querySelectorAll('sl-menu-item')] as SlMenuItem[]) : [];
|
||||||
const firstMenuItem = menuItems[0];
|
const firstMenuItem = menuItems[0];
|
||||||
const lastMenuItem = menuItems[menuItems.length - 1];
|
const lastMenuItem = menuItems[menuItems.length - 1];
|
||||||
|
|
||||||
|
@ -370,42 +287,120 @@ export class Dropdown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shows the dropdown panel */
|
||||||
|
show() {
|
||||||
|
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
||||||
|
if (this.isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slShow = this.emit('sl-show');
|
||||||
|
if (slShow.defaultPrevented) {
|
||||||
|
this.open = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.panel.addEventListener('sl-activate', this.handleMenuItemActivate);
|
||||||
|
this.panel.addEventListener('sl-select', this.handlePanelSelect);
|
||||||
|
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||||
|
document.addEventListener('mousedown', this.handleDocumentMouseDown);
|
||||||
|
|
||||||
|
this.isVisible = true;
|
||||||
|
this.open = true;
|
||||||
|
this.popover.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hides the dropdown panel */
|
||||||
|
hide() {
|
||||||
|
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
||||||
|
if (!this.isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slHide = this.emit('sl-hide');
|
||||||
|
if (slHide.defaultPrevented) {
|
||||||
|
this.open = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.panel.removeEventListener('sl-activate', this.handleMenuItemActivate);
|
||||||
|
this.panel.removeEventListener('sl-select', this.handlePanelSelect);
|
||||||
|
document.addEventListener('keydown', this.handleDocumentKeyDown);
|
||||||
|
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
|
||||||
|
|
||||||
|
this.isVisible = false;
|
||||||
|
this.open = false;
|
||||||
|
this.popover.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instructs the dropdown menu to reposition. Useful when the position or size of the trigger changes when the menu
|
||||||
|
* is activated.
|
||||||
|
*/
|
||||||
|
reposition() {
|
||||||
|
if (!this.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.popover.reposition();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDistance() {
|
||||||
|
this.handlePopoverOptionsChange();
|
||||||
|
}
|
||||||
|
watchHoist() {
|
||||||
|
this.handlePopoverOptionsChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchOpen() {
|
||||||
|
this.open ? this.show() : this.hide();
|
||||||
|
this.updateAccessibleTrigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchPlacement() {
|
||||||
|
this.handlePopoverOptionsChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchSkidding() {
|
||||||
|
this.handlePopoverOptionsChange();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return html`
|
||||||
<div
|
<div
|
||||||
part="base"
|
part="base"
|
||||||
id={this.componentId}
|
id=${this.componentId}
|
||||||
class={{
|
class=${classMap({
|
||||||
dropdown: true,
|
dropdown: true,
|
||||||
'dropdown--open': this.open
|
'dropdown--open': this.open
|
||||||
}}
|
})}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
part="trigger"
|
part="trigger"
|
||||||
class="dropdown__trigger"
|
class="dropdown__trigger"
|
||||||
ref={el => (this.trigger = el)}
|
ref=${(el: HTMLElement) => (this.trigger = el)}
|
||||||
onClick={this.handleTriggerClick}
|
onclick=${this.handleTriggerClick.bind(this)}
|
||||||
onKeyDown={this.handleTriggerKeyDown}
|
onkeydown=${this.handleTriggerKeyDown.bind(this)}
|
||||||
onKeyUp={this.handleTriggerKeyUp}
|
onkeyup=${this.handleTriggerKeyUp.bind(this)}
|
||||||
>
|
>
|
||||||
<slot name="trigger" onSlotchange={this.handleTriggerSlotChange} />
|
<slot name="trigger" onslotchange=${this.handleTriggerSlotChange.bind(this)} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Position the panel with a wrapper since the popover makes use of `translate`. This let's us add transitions
|
<!-- Position the panel with a wrapper since the popover makes use of translate. This let's us add transitions
|
||||||
on the panel without interfering with the position. */}
|
on the panel without interfering with the position. -->
|
||||||
<div ref={el => (this.positioner = el)} class="dropdown__positioner">
|
<div ref=${(el: HTMLElement) => (this.positioner = el)} class="dropdown__positioner">
|
||||||
<div
|
<div
|
||||||
ref={el => (this.panel = el)}
|
ref=${(el: HTMLElement) => (this.panel = el)}
|
||||||
part="panel"
|
part="panel"
|
||||||
class="dropdown__panel"
|
class="dropdown__panel"
|
||||||
role="menu"
|
role="menu"
|
||||||
aria-hidden={this.open ? 'false' : 'true'}
|
aria-hidden=${this.open ? 'false' : 'true'}
|
||||||
aria-labelledby={this.componentId}
|
aria-labelledby=${this.componentId}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,88 +0,0 @@
|
||||||
import { newE2EPage } from '@stencil/core/testing';
|
|
||||||
|
|
||||||
const testForm = `
|
|
||||||
<sl-form class="form-overview">
|
|
||||||
<sl-input name="name" type="text" label="Name" value="Mr. Meow"></sl-input>
|
|
||||||
<br>
|
|
||||||
<sl-select name="favorite" label="Select your favorite" value="cats">
|
|
||||||
<sl-menu-item value="birds">Birds</sl-menu-item>
|
|
||||||
<sl-menu-item value="cats">Cats</sl-menu-item>
|
|
||||||
<sl-menu-item value="dogs">Dogs</sl-menu-item>
|
|
||||||
</sl-select>
|
|
||||||
<br>
|
|
||||||
<sl-checkbox name="agree" value="yes" checked>
|
|
||||||
I agree
|
|
||||||
</sl-checkbox>
|
|
||||||
<br><br>
|
|
||||||
<sl-button submit>Submit</sl-button>
|
|
||||||
</sl-form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
describe('<sl-form>', () => {
|
|
||||||
it('should emit sl-submit when submit button clicked', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: testForm
|
|
||||||
});
|
|
||||||
const form = await page.find('sl-form');
|
|
||||||
const button = await page.find('sl-button');
|
|
||||||
const slSubmit = await form.spyOnEvent('sl-submit');
|
|
||||||
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
expect(slSubmit).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-submit when submit method called', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: testForm
|
|
||||||
});
|
|
||||||
const form = await page.find('sl-form');
|
|
||||||
const slSubmit = await form.spyOnEvent('sl-submit');
|
|
||||||
|
|
||||||
await form.callMethod('submit');
|
|
||||||
|
|
||||||
expect(slSubmit).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit sl-submit when enter pressed inside an input', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: testForm
|
|
||||||
});
|
|
||||||
const form = await page.find('sl-form');
|
|
||||||
const inputControl = await page.find('sl-input >>> .input__control');
|
|
||||||
const slSubmit = await form.spyOnEvent('sl-submit');
|
|
||||||
|
|
||||||
await inputControl.press('Enter');
|
|
||||||
|
|
||||||
expect(slSubmit).toHaveReceivedEventTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return array of form elements when getFormControls() is called', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: testForm
|
|
||||||
});
|
|
||||||
const form = await page.find('sl-form');
|
|
||||||
const inputEl = await page.$eval('sl-input', el => el);
|
|
||||||
const selectEl = await page.$eval('sl-select', el => el);
|
|
||||||
const checkboxEl = await page.$eval('sl-checkbox', el => el);
|
|
||||||
const buttonEl = await page.$eval('sl-button', el => el);
|
|
||||||
const formControls = await form.callMethod('getFormControls');
|
|
||||||
|
|
||||||
expect(formControls).toEqual([inputEl, selectEl, checkboxEl, buttonEl]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return FormData object when getFormData() is called', async () => {
|
|
||||||
const page = await newE2EPage({
|
|
||||||
html: testForm
|
|
||||||
});
|
|
||||||
const formData = await page.$eval('sl-form', async (el: HTMLSlFormElement) => [
|
|
||||||
...(await el.getFormData()).entries()
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(formData).toEqual([
|
|
||||||
['name', 'Mr. Meow'],
|
|
||||||
['favorite', 'cats'],
|
|
||||||
['agree', 'yes']
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -1,4 +1,16 @@
|
||||||
import { Component, Event, EventEmitter, Method, Prop, h } from '@stencil/core';
|
import { html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./form.scss';
|
||||||
|
import {
|
||||||
|
SlButton,
|
||||||
|
SlCheckbox,
|
||||||
|
SlColorPicker,
|
||||||
|
SlInput,
|
||||||
|
SlRadio,
|
||||||
|
SlRange,
|
||||||
|
SlSelect,
|
||||||
|
SlSwitch,
|
||||||
|
SlTextarea
|
||||||
|
} from '../../shoelace';
|
||||||
|
|
||||||
interface FormControl {
|
interface FormControl {
|
||||||
tag: string;
|
tag: string;
|
||||||
|
@ -14,29 +26,25 @@ interface FormControl {
|
||||||
* @slot - The form's content.
|
* @slot - The form's content.
|
||||||
*
|
*
|
||||||
* @part base - The component's base wrapper.
|
* @part base - The component's base wrapper.
|
||||||
|
*
|
||||||
|
* @emit sl-submit - Emitted when the form is submitted. This event will not be emitted if any form control inside of
|
||||||
|
* it is in an invalid state, unless the form has the `novalidate` attribute. Note that there is never a need to prevent
|
||||||
|
* this event, since it doen't send a GET or POST request like native forms. To "prevent" submission, use a conditional
|
||||||
|
* around the XHR request you use to submit the form's data with. Event details will contain:
|
||||||
|
* `{ formData: FormData; formControls: HTMLElement[] }`
|
||||||
*/
|
*/
|
||||||
|
export default class SlForm extends Shoemaker {
|
||||||
|
static tag = 'sl-form';
|
||||||
|
static props = ['novalidate'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
@Component({
|
private form: HTMLElement;
|
||||||
tag: 'sl-form',
|
private formControls: FormControl[];
|
||||||
styleUrl: 'form.scss',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class Form {
|
|
||||||
form: HTMLElement;
|
|
||||||
formControls: FormControl[];
|
|
||||||
|
|
||||||
/** Prevent the form from validating inputs before submitting. */
|
/** Prevent the form from validating inputs before submitting. */
|
||||||
@Prop() novalidate = false;
|
novalidate = false;
|
||||||
|
|
||||||
/**
|
onConnect() {
|
||||||
* Emitted when the form is submitted. This event will not be emitted if any form control inside of it is in an
|
|
||||||
* invalid state, unless the form has the `novalidate` attribute. Note that there is never a need to prevent this
|
|
||||||
* event, since it doen't send a GET or POST request like native forms. To "prevent" submission, use a conditional
|
|
||||||
* around the XHR request you use to submit the form's data with.
|
|
||||||
*/
|
|
||||||
@Event({ eventName: 'sl-submit' }) slSubmit: EventEmitter<{ formData: FormData; formControls: HTMLElement[] }>;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.formControls = [
|
this.formControls = [
|
||||||
{
|
{
|
||||||
tag: 'button',
|
tag: 'button',
|
||||||
|
@ -61,7 +69,7 @@ export class Form {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (el.type === 'file') {
|
if (el.type === 'file') {
|
||||||
[...el.files].map(file => formData.append(el.name, file));
|
[...(el.files as FileList)].map(file => formData.append(el.name, file));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,10 +111,9 @@ export class Form {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'sl-button',
|
tag: 'sl-button',
|
||||||
serialize: (el: HTMLSlButtonElement, formData) =>
|
serialize: (el: SlButton, formData) => (el.name && !el.disabled ? formData.append(el.name, el.value) : null),
|
||||||
el.name && !el.disabled ? formData.append(el.name, el.value) : null,
|
|
||||||
click: event => {
|
click: event => {
|
||||||
const target = event.target as HTMLSlButtonElement;
|
const target = event.target as SlButton;
|
||||||
if (target.submit) {
|
if (target.submit) {
|
||||||
this.submit();
|
this.submit();
|
||||||
}
|
}
|
||||||
|
@ -114,18 +121,17 @@ export class Form {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'sl-checkbox',
|
tag: 'sl-checkbox',
|
||||||
serialize: (el: HTMLSlCheckboxElement, formData) =>
|
serialize: (el: SlCheckbox, formData) =>
|
||||||
el.name && el.checked && !el.disabled ? formData.append(el.name, el.value) : null
|
el.name && el.checked && !el.disabled ? formData.append(el.name, el.value) : null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'sl-color-picker',
|
tag: 'sl-color-picker',
|
||||||
serialize: (el: HTMLSlCheckboxElement, formData) =>
|
serialize: (el: SlColorPicker, formData) =>
|
||||||
el.name && !el.disabled ? formData.append(el.name, el.value) : null
|
el.name && !el.disabled ? formData.append(el.name, el.value) : null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'sl-input',
|
tag: 'sl-input',
|
||||||
serialize: (el: HTMLSlInputElement, formData) =>
|
serialize: (el: SlInput, formData) => (el.name && !el.disabled ? formData.append(el.name, el.value) : null),
|
||||||
el.name && !el.disabled ? formData.append(el.name, el.value) : null,
|
|
||||||
keyDown: event => {
|
keyDown: event => {
|
||||||
if (event.key === 'Enter' && !event.defaultPrevented) {
|
if (event.key === 'Enter' && !event.defaultPrevented) {
|
||||||
this.submit();
|
this.submit();
|
||||||
|
@ -134,12 +140,12 @@ export class Form {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'sl-radio',
|
tag: 'sl-radio',
|
||||||
serialize: (el: HTMLSlRadioElement, formData) =>
|
serialize: (el: SlRadio, formData) =>
|
||||||
el.name && el.checked && !el.disabled ? formData.append(el.name, el.value) : null
|
el.name && el.checked && !el.disabled ? formData.append(el.name, el.value) : null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'sl-range',
|
tag: 'sl-range',
|
||||||
serialize: (el: HTMLSlRangeElement, formData) => {
|
serialize: (el: SlRange, formData) => {
|
||||||
if (el.name && !el.disabled) {
|
if (el.name && !el.disabled) {
|
||||||
formData.append(el.name, el.value + '');
|
formData.append(el.name, el.value + '');
|
||||||
}
|
}
|
||||||
|
@ -147,7 +153,7 @@ export class Form {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'sl-select',
|
tag: 'sl-select',
|
||||||
serialize: (el: HTMLSlSelectElement, formData) => {
|
serialize: (el: SlSelect, formData) => {
|
||||||
if (el.name && !el.disabled) {
|
if (el.name && !el.disabled) {
|
||||||
if (el.multiple) {
|
if (el.multiple) {
|
||||||
const selectedOptions = [...el.value];
|
const selectedOptions = [...el.value];
|
||||||
|
@ -164,13 +170,12 @@ export class Form {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'sl-switch',
|
tag: 'sl-switch',
|
||||||
serialize: (el: HTMLSlSwitchElement, formData) =>
|
serialize: (el: SlSwitch, formData) =>
|
||||||
el.name && el.checked && !el.disabled ? formData.append(el.name, el.value) : null
|
el.name && el.checked && !el.disabled ? formData.append(el.name, el.value) : null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'sl-textarea',
|
tag: 'sl-textarea',
|
||||||
serialize: (el: HTMLSlTextareaElement, formData) =>
|
serialize: (el: SlTextarea, formData) => (el.name && !el.disabled ? formData.append(el.name, el.value) : null)
|
||||||
el.name && !el.disabled ? formData.append(el.name, el.value) : null
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'textarea',
|
tag: 'textarea',
|
||||||
|
@ -184,10 +189,9 @@ export class Form {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serializes all form controls elements and returns a `FormData` object. */
|
/** Serializes all form controls elements and returns a `FormData` object. */
|
||||||
@Method()
|
getFormData() {
|
||||||
async getFormData() {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
const formControls = await this.getFormControls();
|
const formControls = this.getFormControls();
|
||||||
|
|
||||||
formControls.map(el => this.serializeElement(el, formData));
|
formControls.map(el => this.serializeElement(el, formData));
|
||||||
|
|
||||||
|
@ -195,29 +199,30 @@ export class Form {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gets all form control elements (native and custom). */
|
/** Gets all form control elements (native and custom). */
|
||||||
@Method()
|
getFormControls() {
|
||||||
async getFormControls() {
|
const slot = this.form.querySelector('slot')!;
|
||||||
const slot = this.form.querySelector('slot');
|
|
||||||
const tags = this.formControls.map(control => control.tag);
|
const tags = this.formControls.map(control => control.tag);
|
||||||
return slot
|
return slot
|
||||||
.assignedElements({ flatten: true })
|
.assignedElements({ flatten: true })
|
||||||
.reduce((all, el) => all.concat(el, [...el.querySelectorAll('*')]), [])
|
.reduce(
|
||||||
.filter(el => tags.includes(el.tagName.toLowerCase())) as HTMLElement[];
|
(all: HTMLElement[], el: HTMLElement) => all.concat(el, [...el.querySelectorAll('*')] as HTMLElement[]),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
.filter((el: HTMLElement) => tags.includes(el.tagName.toLowerCase())) as HTMLElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submits the form. If all controls are valid, the `sl-submit` event will be emitted and the promise will resolve
|
* Submits the form. If all controls are valid, the `sl-submit` event will be emitted and the promise will resolve
|
||||||
* with `true`. If any form control is invalid, the promise will resolve with `false` and no event will be emitted.
|
* with `true`. If any form control is invalid, the promise will resolve with `false` and no event will be emitted.
|
||||||
*/
|
*/
|
||||||
@Method()
|
submit() {
|
||||||
async submit() {
|
const formData = this.getFormData();
|
||||||
const formData = await this.getFormData();
|
const formControls = this.getFormControls();
|
||||||
const formControls = await this.getFormControls();
|
|
||||||
const formControlsThatReport = formControls.filter((el: any) => typeof el.reportValidity === 'function') as any;
|
const formControlsThatReport = formControls.filter((el: any) => typeof el.reportValidity === 'function') as any;
|
||||||
|
|
||||||
if (!this.novalidate) {
|
if (!this.novalidate) {
|
||||||
for (const el of formControlsThatReport) {
|
for (const el of formControlsThatReport) {
|
||||||
const isValid = await el.reportValidity();
|
const isValid = el.reportValidity();
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -225,7 +230,7 @@ export class Form {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.slSubmit.emit({ formData, formControls });
|
this.emit('sl-submit', { detail: { formData, formControls } });
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -265,17 +270,17 @@ export class Form {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return html`
|
||||||
<div
|
<div
|
||||||
ref={el => (this.form = el)}
|
ref=${(el: HTMLElement) => (this.form = el)}
|
||||||
part="base"
|
part="base"
|
||||||
class="form"
|
class="form"
|
||||||
role="form"
|
role="form"
|
||||||
onClick={this.handleClick}
|
onclick=${this.handleClick.bind(this)}
|
||||||
onKeyDown={this.handleKeyDown}
|
onkeydown=${this.handleKeyDown.bind(this)}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import { formatBytes } from '../../internal/number';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @since 2.0
|
||||||
|
* @status stable
|
||||||
|
*/
|
||||||
|
export default class SlFormatBytes extends Shoemaker {
|
||||||
|
static tag = 'sl-format-bytes';
|
||||||
|
static props = ['value', 'unit', 'locale'];
|
||||||
|
|
||||||
|
/** The number to format in bytes. */
|
||||||
|
value = 0;
|
||||||
|
|
||||||
|
/** The unit to display. */
|
||||||
|
unit: 'bytes' | 'bits' = 'bytes';
|
||||||
|
|
||||||
|
/** The locale to use when formatting the number. */
|
||||||
|
locale: string;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return formatBytes(this.value, {
|
||||||
|
unit: this.unit,
|
||||||
|
locale: this.locale
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,29 +0,0 @@
|
||||||
import { Component, Prop } from '@stencil/core';
|
|
||||||
import { formatBytes } from '../../utilities/number';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @since 2.0
|
|
||||||
* @status stable
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'sl-format-bytes',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class FormatBytes {
|
|
||||||
/** The number to format in bytes. */
|
|
||||||
@Prop() value = 0;
|
|
||||||
|
|
||||||
/** The unit to display. */
|
|
||||||
@Prop() unit: 'bytes' | 'bits' = 'bytes';
|
|
||||||
|
|
||||||
/** The locale to use when formatting the number. */
|
|
||||||
@Prop() locale: string;
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return formatBytes(this.value, {
|
|
||||||
unit: this.unit,
|
|
||||||
locale: this.locale
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,53 +1,65 @@
|
||||||
import { Component, Prop } from '@stencil/core';
|
import { Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
* @status stable
|
* @status stable
|
||||||
*/
|
*/
|
||||||
|
export default class SlFormatDate extends Shoemaker {
|
||||||
|
static tag = 'sl-format-date';
|
||||||
|
static props = [
|
||||||
|
'date',
|
||||||
|
'locale',
|
||||||
|
'weekday',
|
||||||
|
'era',
|
||||||
|
'year',
|
||||||
|
'month',
|
||||||
|
'day',
|
||||||
|
'hour',
|
||||||
|
'minute',
|
||||||
|
'second',
|
||||||
|
'timeZoneName',
|
||||||
|
'timeZone',
|
||||||
|
'hourFormat'
|
||||||
|
];
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'sl-format-date',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class FormatBytes {
|
|
||||||
/** The date/time to format. If not set, the current date and time will be used. */
|
/** The date/time to format. If not set, the current date and time will be used. */
|
||||||
@Prop() date: Date | string = new Date();
|
date: Date | string = new Date();
|
||||||
|
|
||||||
/** The locale to use when formatting the date/time. */
|
/** The locale to use when formatting the date/time. */
|
||||||
@Prop() locale: string;
|
locale: string;
|
||||||
|
|
||||||
/** The format for displaying the weekday. */
|
/** The format for displaying the weekday. */
|
||||||
@Prop() weekday: 'narrow' | 'short' | 'long';
|
weekday: 'narrow' | 'short' | 'long';
|
||||||
|
|
||||||
/** The format for displaying the era. */
|
/** The format for displaying the era. */
|
||||||
@Prop() era: 'narrow' | 'short' | 'long';
|
era: 'narrow' | 'short' | 'long';
|
||||||
|
|
||||||
/** The format for displaying the year. */
|
/** The format for displaying the year. */
|
||||||
@Prop() year: 'numeric' | '2-digit';
|
year: 'numeric' | '2-digit';
|
||||||
|
|
||||||
/** The format for displaying the month. */
|
/** The format for displaying the month. */
|
||||||
@Prop() month: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long';
|
month: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long';
|
||||||
|
|
||||||
/** The format for displaying the day. */
|
/** The format for displaying the day. */
|
||||||
@Prop() day: 'numeric' | '2-digit';
|
day: 'numeric' | '2-digit';
|
||||||
|
|
||||||
/** The format for displaying the hour. */
|
/** The format for displaying the hour. */
|
||||||
@Prop() hour: 'numeric' | '2-digit';
|
hour: 'numeric' | '2-digit';
|
||||||
|
|
||||||
/** The format for displaying the minute. */
|
/** The format for displaying the minute. */
|
||||||
@Prop() minute: 'numeric' | '2-digit';
|
minute: 'numeric' | '2-digit';
|
||||||
|
|
||||||
/** The format for displaying the second. */
|
/** The format for displaying the second. */
|
||||||
@Prop() second: 'numeric' | '2-digit';
|
second: 'numeric' | '2-digit';
|
||||||
|
|
||||||
/** The format for displaying the time. */
|
/** The format for displaying the time. */
|
||||||
@Prop() timeZoneName: 'short' | 'long';
|
timeZoneName: 'short' | 'long';
|
||||||
|
|
||||||
/** The time zone to express the time in. */
|
/** The time zone to express the time in. */
|
||||||
@Prop() timeZone: string;
|
timeZone: string;
|
||||||
|
|
||||||
/** When set, 24 hour time will always be used. */
|
/** When set, 24 hour time will always be used. */
|
||||||
@Prop() hourFormat: 'auto' | '12' | '24' = 'auto';
|
hourFormat: 'auto' | '12' | '24' = 'auto';
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const date = new Date(this.date);
|
const date = new Date(this.date);
|
|
@ -1,47 +1,57 @@
|
||||||
import { Component, Prop } from '@stencil/core';
|
import { Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since 2.0
|
* @since 2.0
|
||||||
* @status stable
|
* @status stable
|
||||||
*/
|
*/
|
||||||
|
export default class SlFormatNumber extends Shoemaker {
|
||||||
|
static tag = 'sl-format-number';
|
||||||
|
static props = [
|
||||||
|
'value',
|
||||||
|
'locale',
|
||||||
|
'type',
|
||||||
|
'noGrouping',
|
||||||
|
'currency',
|
||||||
|
'currencyDisplay',
|
||||||
|
'minimumIntegerDigits',
|
||||||
|
'minimumFractionDigits',
|
||||||
|
'maximumFractionDigits',
|
||||||
|
'minimumSignificantDigits',
|
||||||
|
'maximumSignificantDigits'
|
||||||
|
];
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'sl-format-number',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class FormatBytes {
|
|
||||||
/** The number to format. */
|
/** The number to format. */
|
||||||
@Prop() value = 0;
|
value = 0;
|
||||||
|
|
||||||
/** The locale to use when formatting the number. */
|
/** The locale to use when formatting the number. */
|
||||||
@Prop() locale: string;
|
locale: string;
|
||||||
|
|
||||||
/** The formatting style to use. */
|
/** The formatting style to use. */
|
||||||
@Prop() type: 'currency' | 'decimal' | 'percent' = 'decimal';
|
type: 'currency' | 'decimal' | 'percent' = 'decimal';
|
||||||
|
|
||||||
/** Turns off grouping separators. */
|
/** Turns off grouping separators. */
|
||||||
@Prop() noGrouping = false;
|
noGrouping = false;
|
||||||
|
|
||||||
/** The currency to use when formatting. Must be an ISO 4217 currency code such as `USD` or `EUR`. */
|
/** The currency to use when formatting. Must be an ISO 4217 currency code such as `USD` or `EUR`. */
|
||||||
@Prop() currency = 'USD';
|
currency = 'USD';
|
||||||
|
|
||||||
/** How to display the currency. */
|
/** How to display the currency. */
|
||||||
@Prop() currencyDisplay: 'symbol' | 'narrowSymbol' | 'code' | 'name' = 'symbol';
|
currencyDisplay: 'symbol' | 'narrowSymbol' | 'code' | 'name' = 'symbol';
|
||||||
|
|
||||||
/** The minimum number of integer digits to use. Possible values are 1 - 21. */
|
/** The minimum number of integer digits to use. Possible values are 1 - 21. */
|
||||||
@Prop() minimumIntegerDigits: number;
|
minimumIntegerDigits: number;
|
||||||
|
|
||||||
/** The minimum number of fraction digits to use. Possible values are 0 - 20. */
|
/** The minimum number of fraction digits to use. Possible values are 0 - 20. */
|
||||||
@Prop() minimumFractionDigits: number;
|
minimumFractionDigits: number;
|
||||||
|
|
||||||
/** The maximum number of fraction digits to use. Possible values are 0 - 20. */
|
/** The maximum number of fraction digits to use. Possible values are 0 - 20. */
|
||||||
@Prop() maximumFractionDigits: number;
|
maximumFractionDigits: number;
|
||||||
|
|
||||||
/** The minimum number of significant digits to use. Possible values are 1 - 21. */
|
/** The minimum number of significant digits to use. Possible values are 1 - 21. */
|
||||||
@Prop() minimumSignificantDigits: number;
|
minimumSignificantDigits: number;
|
||||||
|
|
||||||
/** The maximum number of significant digits to use,. Possible values are 1 - 21. */
|
/** The maximum number of significant digits to use,. Possible values are 1 - 21. */
|
||||||
@Prop() maximumSignificantDigits: number;
|
maximumSignificantDigits: number;
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (isNaN(this.value)) {
|
if (isNaN(this.value)) {
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
|
import styles from 'sass:./icon-button.scss';
|
||||||
|
import { focusVisible } from '../../internal/focus-visible';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @since 2.0
|
||||||
|
* @status stable
|
||||||
|
*
|
||||||
|
* @dependency sl-icon
|
||||||
|
*
|
||||||
|
* @part base - The component's base wrapper.
|
||||||
|
*/
|
||||||
|
export default class SlIconButton extends Shoemaker {
|
||||||
|
static tag = 'sl-icon-button';
|
||||||
|
static props = ['name', 'library', 'src', 'label', 'disabled'];
|
||||||
|
static reflect = ['disabled'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
private button: HTMLButtonElement;
|
||||||
|
|
||||||
|
/** The name of the icon to draw. */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** The name of a registered custom icon library. */
|
||||||
|
library: string;
|
||||||
|
|
||||||
|
/** An external URL of an SVG file. */
|
||||||
|
src: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A description that gets read by screen readers and other assistive devices. For optimal accessibility, you should
|
||||||
|
* always include a label that describes what the icon button does.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/** Disables the button. */
|
||||||
|
disabled = false;
|
||||||
|
|
||||||
|
onReady() {
|
||||||
|
focusVisible.observe(this.button);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
focusVisible.unobserve(this.button);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
ref=${(el: HTMLButtonElement) => (this.button = el)}
|
||||||
|
part="base"
|
||||||
|
class=${classMap({
|
||||||
|
'icon-button': true,
|
||||||
|
'icon-button--disabled': this.disabled
|
||||||
|
})}
|
||||||
|
type="button"
|
||||||
|
aria-label=${this.label}
|
||||||
|
>
|
||||||
|
<sl-icon library=${this.library} name=${this.name} src=${this.src} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,61 +0,0 @@
|
||||||
import { Component, Prop, h } from '@stencil/core';
|
|
||||||
import { focusVisible } from '../../utilities/focus-visible';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @since 2.0
|
|
||||||
* @status stable
|
|
||||||
*
|
|
||||||
* @part base - The component's base wrapper.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'sl-icon-button',
|
|
||||||
styleUrl: 'icon-button.scss',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class IconButton {
|
|
||||||
button: HTMLButtonElement;
|
|
||||||
|
|
||||||
/** The name of the icon to draw. */
|
|
||||||
@Prop({ reflect: true }) name: string;
|
|
||||||
|
|
||||||
/** The name of a registered custom icon library. */
|
|
||||||
@Prop({ reflect: true }) library: string;
|
|
||||||
|
|
||||||
/** An external URL of an SVG file. */
|
|
||||||
@Prop({ reflect: true }) src: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A description that gets read by screen readers and other assistive devices. For optimal accessibility, you should
|
|
||||||
* always include a label that describes what the icon button does.
|
|
||||||
*/
|
|
||||||
@Prop({ reflect: true }) label: string;
|
|
||||||
|
|
||||||
/** Set to true to disable the button. */
|
|
||||||
@Prop({ reflect: true }) disabled = false;
|
|
||||||
|
|
||||||
componentDidLoad() {
|
|
||||||
focusVisible.observe(this.button);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
focusVisible.unobserve(this.button);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={el => (this.button = el)}
|
|
||||||
part="base"
|
|
||||||
class={{
|
|
||||||
'icon-button': true,
|
|
||||||
'icon-button--disabled': this.disabled
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
aria-label={this.label}
|
|
||||||
>
|
|
||||||
<sl-icon library={this.library} name={this.name} src={this.src} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
:host {
|
|
||||||
display: none;
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
import { Component, Prop, Watch } from '@stencil/core';
|
|
||||||
import { registerLibrary, unregisterLibrary, IconLibraryResolver, IconLibraryMutator } from './icon-library-registry';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @since 2.0
|
|
||||||
* @status stable
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'sl-icon-library',
|
|
||||||
styleUrl: 'icon-library.scss',
|
|
||||||
shadow: true
|
|
||||||
})
|
|
||||||
export class IconLibrary {
|
|
||||||
/** The name of the icon library. */
|
|
||||||
@Prop() name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A function that translates an icon name to a URL where the corresponding SVG file exists The URL can be local or a
|
|
||||||
* CORS-enabled endpoint.
|
|
||||||
*/
|
|
||||||
@Prop() resolver: IconLibraryResolver;
|
|
||||||
|
|
||||||
/** A function that mutates the SVG element before it renders. */
|
|
||||||
@Prop() mutator: IconLibraryMutator;
|
|
||||||
|
|
||||||
@Watch('name')
|
|
||||||
@Watch('resolver')
|
|
||||||
@Watch('mutator')
|
|
||||||
handleUpdate() {
|
|
||||||
// Subsequent registrations with the same name will invalidate existing ones
|
|
||||||
this.register();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
if (this.name && this.resolver) {
|
|
||||||
this.register();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
unregisterLibrary(this.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
register() {
|
|
||||||
const { name, resolver, mutator } = this;
|
|
||||||
registerLibrary(name, resolver, mutator);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
@import 'component';
|
@use '../../styles/component';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
|
import { html, Hole, Shoemaker } from '@shoelace-style/shoemaker';
|
||||||
import { getLibrary, watchIcon, unwatchIcon } from '../icon-library/icon-library-registry';
|
import styles from 'sass:./icon.scss';
|
||||||
|
import { getIconLibrary, watchIcon, unwatchIcon } from './library';
|
||||||
import { requestIcon } from './request';
|
import { requestIcon } from './request';
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
|
@ -9,60 +10,39 @@ const parser = new DOMParser();
|
||||||
* @status stable
|
* @status stable
|
||||||
*
|
*
|
||||||
* @part base - The component's base wrapper.
|
* @part base - The component's base wrapper.
|
||||||
|
*
|
||||||
|
* @emit sl-load - Emitted when the icon has loaded.
|
||||||
|
* @emit sl-error - Emitted when the icon failed to load. Event details may include: `{ status: number }`
|
||||||
*/
|
*/
|
||||||
|
export default class SlIcon extends Shoemaker {
|
||||||
|
static tag = 'sl-icon';
|
||||||
|
static props = ['svg', 'name', 'src', 'label', 'library'];
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
@Component({
|
private svg: Hole | string;
|
||||||
tag: 'sl-icon',
|
|
||||||
styleUrl: 'icon.scss',
|
|
||||||
shadow: true,
|
|
||||||
assetsDirs: ['icons']
|
|
||||||
})
|
|
||||||
export class Icon {
|
|
||||||
@Element() host: HTMLSlIconElement;
|
|
||||||
|
|
||||||
@State() svg: string;
|
|
||||||
|
|
||||||
/** The name of the icon to draw. */
|
/** The name of the icon to draw. */
|
||||||
@Prop() name: string;
|
name: string;
|
||||||
|
|
||||||
/** An external URL of an SVG file. */
|
/** An external URL of an SVG file. */
|
||||||
@Prop() src: string;
|
src: string;
|
||||||
|
|
||||||
/** An alternative description to use for accessibility. If omitted, the name or src will be used to generate it. */
|
/** An alternative description to use for accessibility. If omitted, the name or src will be used to generate it. */
|
||||||
@Prop() label: string;
|
label: string;
|
||||||
|
|
||||||
/** The name of a registered custom icon library. */
|
/** The name of a registered custom icon library. */
|
||||||
@Prop() library = 'default';
|
library = 'default';
|
||||||
|
|
||||||
/** Emitted when the icon has loaded. */
|
onConnect() {
|
||||||
@Event({ eventName: 'sl-load' }) slLoad: EventEmitter;
|
watchIcon(this);
|
||||||
|
}
|
||||||
|
|
||||||
/** Emitted when the icon failed to load. */
|
onReady() {
|
||||||
@Event({ eventName: 'sl-error' }) slError: EventEmitter<{ status?: number }>;
|
|
||||||
|
|
||||||
@Watch('name')
|
|
||||||
@Watch('src')
|
|
||||||
@Watch('library')
|
|
||||||
handleChange() {
|
|
||||||
this.setIcon();
|
this.setIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
onDisconnect() {
|
||||||
watchIcon(this.host);
|
unwatchIcon(this);
|
||||||
}
|
|
||||||
|
|
||||||
componentDidLoad() {
|
|
||||||
this.setIcon();
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
unwatchIcon(this.host);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal Fetches the icon and redraws it. Used to handle library registrations. */
|
|
||||||
@Method()
|
|
||||||
async redraw() {
|
|
||||||
this.setIcon();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getLabel() {
|
getLabel() {
|
||||||
|
@ -79,8 +59,13 @@ export class Icon {
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal Fetches the icon and redraws it. Used to handle library registrations. */
|
||||||
|
redraw() {
|
||||||
|
this.setIcon();
|
||||||
|
}
|
||||||
|
|
||||||
async setIcon() {
|
async setIcon() {
|
||||||
const library = getLibrary(this.library);
|
const library = getIconLibrary(this.library);
|
||||||
let url = this.src;
|
let url = this.src;
|
||||||
|
|
||||||
if (this.name && library) {
|
if (this.name && library) {
|
||||||
|
@ -89,35 +74,52 @@ export class Icon {
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
try {
|
try {
|
||||||
const file = await requestIcon(url);
|
const file = await requestIcon(url)!;
|
||||||
if (file.ok) {
|
if (file.ok) {
|
||||||
const doc = parser.parseFromString(file.svg, 'text/html');
|
const doc = parser.parseFromString(file.svg, 'text/html');
|
||||||
const svg = doc.body.querySelector('svg');
|
const svgEl = doc.body.querySelector('svg');
|
||||||
|
|
||||||
if (svg) {
|
if (svgEl) {
|
||||||
if (library && library.mutator) {
|
if (library && library.mutator) {
|
||||||
library.mutator(svg);
|
library.mutator(svgEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.svg = svg.outerHTML;
|
this.svg = html([svgEl.outerHTML] as any);
|
||||||
this.slLoad.emit();
|
this.emit('sl-load');
|
||||||
} else {
|
} else {
|
||||||
this.svg = '';
|
this.svg = '';
|
||||||
this.slError.emit({ status: file.status });
|
this.emit('sl-error', { detail: { status: file.status } });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.slError.emit({ status: file.status });
|
this.svg = '';
|
||||||
|
this.emit('sl-error', { detail: { status: file.status } });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.slError.emit();
|
this.emit('sl-error');
|
||||||
}
|
}
|
||||||
} else if (this.svg) {
|
} else if (this.svg) {
|
||||||
// If we can't resolve a URL and an icon was previously set, remove it
|
// If we can't resolve a URL and an icon was previously set, remove it
|
||||||
this.svg = null;
|
this.svg = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watchName() {
|
||||||
|
this.setIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchSrc() {
|
||||||
|
this.setIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchLibrary() {
|
||||||
|
this.setIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange() {
|
||||||
|
this.setIcon();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <div part="base" class="icon" role="img" aria-label={this.getLabel()} innerHTML={this.svg} />;
|
return html` <div part="base" class="icon" role="img" aria-label=${this.getLabel()}>${this.svg}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,9 @@
|
||||||
import { getAssetPath } from '@stencil/core';
|
import Icon from './icon';
|
||||||
|
import { getBasePath } from '../../utilities/base-path';
|
||||||
|
|
||||||
export type IconLibraryResolver = (name: string) => string;
|
export type IconLibraryResolver = (name: string) => string;
|
||||||
export type IconLibraryMutator = (svg: SVGElement) => void;
|
export type IconLibraryMutator = (svg: SVGElement) => void;
|
||||||
|
|
||||||
interface IconLibraryRegistry {
|
interface IconLibraryRegistry {
|
||||||
name: string;
|
name: string;
|
||||||
resolver: IconLibraryResolver;
|
resolver: IconLibraryResolver;
|
||||||
|
@ -11,26 +13,33 @@ interface IconLibraryRegistry {
|
||||||
let registry: IconLibraryRegistry[] = [
|
let registry: IconLibraryRegistry[] = [
|
||||||
{
|
{
|
||||||
name: 'default',
|
name: 'default',
|
||||||
resolver: name => getAssetPath(`./icons/${name}.svg`)
|
resolver: name => `${getBasePath()}/assets/icons/${name}.svg`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
let watchedIcons: HTMLSlIconElement[] = [];
|
let watchedIcons: Icon[] = [];
|
||||||
|
|
||||||
export function watchIcon(icon: HTMLSlIconElement) {
|
export function watchIcon(icon: Icon) {
|
||||||
watchedIcons.push(icon);
|
watchedIcons.push(icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unwatchIcon(icon: HTMLSlIconElement) {
|
export function unwatchIcon(icon: Icon) {
|
||||||
watchedIcons = watchedIcons.filter(el => el !== icon);
|
watchedIcons = watchedIcons.filter(el => el !== icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLibrary(name?: string) {
|
export function getIconLibrary(name?: string) {
|
||||||
return registry.filter(lib => lib.name === name)[0];
|
return registry.filter(lib => lib.name === name)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerLibrary(name: string, resolver: IconLibraryResolver, mutator?: IconLibraryMutator) {
|
export function registerIconLibrary(
|
||||||
unregisterLibrary(name);
|
name: string,
|
||||||
registry.push({ name, resolver, mutator });
|
options: { resolver: IconLibraryResolver; mutator?: IconLibraryMutator }
|
||||||
|
) {
|
||||||
|
unregisterIconLibrary(name);
|
||||||
|
registry.push({
|
||||||
|
name,
|
||||||
|
resolver: options.resolver,
|
||||||
|
mutator: options.mutator
|
||||||
|
});
|
||||||
|
|
||||||
// Redraw watched icons
|
// Redraw watched icons
|
||||||
watchedIcons.map(icon => {
|
watchedIcons.map(icon => {
|
||||||
|
@ -40,6 +49,6 @@ export function registerLibrary(name: string, resolver: IconLibraryResolver, mut
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unregisterLibrary(name: string) {
|
export function unregisterIconLibrary(name: string) {
|
||||||
registry = registry.filter(lib => lib.name !== name);
|
registry = registry.filter(lib => lib.name !== name);
|
||||||
}
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue